Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/chdir/mr-activity-table/mr-activity-table.js b/static_src/elements/chdir/mr-activity-table/mr-activity-table.js
new file mode 100644
index 0000000..a0f4715
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-activity-table.js
@@ -0,0 +1,129 @@
+// 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 './mr-day-icon.js';
+
+const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'];
+const WEEKDAY_ABBREVIATIONS = 'M T W T F S S'.split(' ');
+const SECONDS_PER_DAY = 24 * 60 * 60;
+// Only show comments from this many days ago and later.
+const MAX_COMMENT_AGE = 31 * 3;
+
+export class MrActivityTable extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: repeat(13, auto);
+ grid-template-rows: repeat(7, auto);
+ margin: auto;
+ width: 90%;
+ text-align: center;
+ line-height: 110%;
+ align-items: center;
+ justify-content: space-between;
+ }
+ :host[hidden] {
+ display: none;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ ${WEEKDAY_ABBREVIATIONS.map((weekday) => html`<span>${weekday}</span>`)}
+ ${this._weekdayOffset.map(() => html`<span></span>`)}
+ ${this._activityArray.map((day) => html`
+ <mr-day-icon
+ .selected=${this.selectedDate === day.date}
+ .commentCount=${day.commentCount}
+ .date=${day.date}
+ @click=${this._selectDay}
+ ></mr-day-icon>
+ `)}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ comments: {type: Array},
+ selectedDate: {type: Number},
+ };
+ }
+
+ _selectDay(event) {
+ const target = event.target;
+ if (this.selectedDate === target.date) {
+ this.selectedDate = undefined;
+ } else {
+ this.selectedDate = target.date;
+ }
+
+ this.dispatchEvent(new CustomEvent('dateChange', {
+ detail: {
+ date: this.selectedDate,
+ },
+ }));
+ }
+
+ get months() {
+ const currentMonth = (new Date()).getMonth();
+ return [MONTH_NAMES[currentMonth],
+ MONTH_NAMES[currentMonth - 1],
+ MONTH_NAMES[currentMonth - 2]];
+ }
+
+ get _weekdayOffset() {
+ const startDate = new Date(this._activityArray[0].date * 1000);
+ const startWeekdayNum = startDate.getDay()-1;
+ const emptyDays = [];
+ for (let i = 0; i < startWeekdayNum; i++) {
+ emptyDays.push(' ');
+ }
+ return emptyDays;
+ }
+
+ get _todayUnixTime() {
+ const now = new Date();
+ const today = new Date(Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate(),
+ 24, 0, 0));
+ const todayEndTime = today.getTime() / 1000;
+ return todayEndTime;
+ }
+
+ get _activityArray() {
+ const todayUnixEndTime = this._todayUnixTime;
+ const comments = this.comments || [];
+
+ const activityArray = [];
+ for (let i = 0; i < MAX_COMMENT_AGE; i++) {
+ const arrayDate = (todayUnixEndTime - ((i) * SECONDS_PER_DAY));
+ activityArray.unshift({
+ commentCount: 0,
+ date: arrayDate,
+ });
+ }
+
+ for (let i = 0; i < comments.length; i++) {
+ const commentAge = Math.floor(
+ (todayUnixEndTime - comments[i].timestamp) / SECONDS_PER_DAY);
+ if (commentAge < MAX_COMMENT_AGE) {
+ const pos = MAX_COMMENT_AGE - commentAge - 1;
+ activityArray[pos].commentCount++;
+ }
+ }
+
+ return activityArray;
+ }
+}
+customElements.define('mr-activity-table', MrActivityTable);
diff --git a/static_src/elements/chdir/mr-activity-table/mr-activity-table.test.js b/static_src/elements/chdir/mr-activity-table/mr-activity-table.test.js
new file mode 100644
index 0000000..0eb9d30
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-activity-table.test.js
@@ -0,0 +1,57 @@
+// 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 {MrActivityTable} from './mr-activity-table.js';
+import sinon from 'sinon';
+
+const SECONDS_PER_DAY = 24 * 60 * 60;
+
+let element;
+
+describe('mr-activity-table', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-activity-table');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrActivityTable);
+ });
+
+ it('no comments makes empty activity array', () => {
+ element.comments = [];
+
+ for (let i = 0; i < 93; i++) {
+ assert.equal(0, element._activityArray[i].commentCount);
+ }
+ });
+
+ it('activity array handles old comments', () => {
+ // 94 days since EPOCH.
+ sinon.stub(element, '_todayUnixTime').get(() => 94 * SECONDS_PER_DAY);
+
+ element.comments = [
+ {content: 'blah', timestamp: 0}, // too old.
+ {content: 'ignore', timestamp: 100}, // too old.
+ {
+ content: 'comment',
+ timestamp: SECONDS_PER_DAY + 1, // barely young enough.
+ },
+ {content: 'hello', timestamp: SECONDS_PER_DAY + 10}, // same day as above.
+ {content: 'world', timestamp: SECONDS_PER_DAY * 94}, // today
+ ];
+
+ assert.equal(93, element._activityArray.length);
+ assert.equal(2, element._activityArray[0].commentCount);
+ for (let i = 1; i < 92; i++) {
+ assert.equal(0, element._activityArray[i].commentCount);
+ }
+ assert.equal(1, element._activityArray[92].commentCount);
+ });
+});
diff --git a/static_src/elements/chdir/mr-activity-table/mr-day-icon.js b/static_src/elements/chdir/mr-activity-table/mr-day-icon.js
new file mode 100644
index 0000000..82f62b3
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-day-icon.js
@@ -0,0 +1,91 @@
+// 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';
+
+export class MrDayIcon extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ background-color: hsl(0, 0%, 95%);
+ margin: 0.25em 8px;
+ height: 20px;
+ width: 20px;
+ border: 2px solid white;
+ transition: border-color .5s ease-in-out;
+ }
+ :host(:hover) {
+ cursor: pointer;
+ border-color: hsl(87, 20%, 45%);
+ }
+ :host([activityLevel="0"]) {
+ background-color: var(--chops-blue-gray-50);
+ }
+ :host([activityLevel="1"]) {
+ background-color: hsl(87, 70%, 87%);
+ }
+ :host([activityLevel="2"]) {
+ background-color: hsl(88, 67%, 72%);
+ }
+ :host([activityLevel="3"]) {
+ background-color: hsl(87, 80%, 40%);
+ }
+ :host([selected]) {
+ border-color: hsl(0, 0%, 13%);
+ }
+ .hover-card {
+ display: none;
+ }
+ :host(:hover) .hover-card {
+ display: block;
+ position: relative;
+ width: 150px;
+ padding: 0.5em 8px;
+ background: rgba(0, 0, 0, 0.6);
+ color: var(--chops-white);
+ border-radius: 8px;
+ top: 120%;
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <div class="hover-card">
+ ${this.commentCount} Comments<br>
+ <chops-timestamp .timestamp=${this.date}></chops-timestamp>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ activityLevel: {
+ type: Number,
+ reflect: true,
+ },
+ commentCount: {type: Number},
+ date: {type: Number},
+ selected: {
+ type: Boolean,
+ reflect: true,
+ },
+ };
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('commentCount')) {
+ const level = Math.ceil(this.commentCount / 2);
+ this.activityLevel = Math.min(level, 3);
+ }
+ super.update(changedProperties);
+ }
+}
+customElements.define('mr-day-icon', MrDayIcon);
diff --git a/static_src/elements/chdir/mr-activity-table/mr-day-icon.test.js b/static_src/elements/chdir/mr-activity-table/mr-day-icon.test.js
new file mode 100644
index 0000000..3c35a10
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-day-icon.test.js
@@ -0,0 +1,24 @@
+// 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 {MrDayIcon} from './mr-day-icon.js';
+
+
+let element;
+
+describe('mr-day-icon', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-day-icon');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrDayIcon);
+ });
+});
diff --git a/static_src/elements/chdir/mr-comment-table/mr-comment-table.js b/static_src/elements/chdir/mr-comment-table/mr-comment-table.js
new file mode 100644
index 0000000..a6d0f19
--- /dev/null
+++ b/static_src/elements/chdir/mr-comment-table/mr-comment-table.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 {LitElement, html, css} from 'lit-element';
+import 'elements/framework/mr-comment-content/mr-comment-content.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+
+/**
+ * `<mr-comment-table>`
+ *
+ * The list of comments for a Monorail Polymer profile.
+ *
+ */
+export class MrCommentTable extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ .ellipsis {
+ max-width: 50%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+ table {
+ word-wrap: break-word;
+ width: 100%;
+ }
+ tr {
+ font-size: var(--chops-main-font-size);
+ font-weight: normal;
+ text-align: left;
+ line-height: 180%;
+ }
+ td, th {
+ border-bottom: var(--chops-normal-border);
+ padding: 0.25em 16px;
+ }
+ td {
+ text-overflow: ellipsis;
+ }
+ th {
+ text-align: left;
+ }
+ .no-wrap {
+ white-space: nowrap;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ const comments = this._displayedComments(this.selectedDate, this.comments);
+ // TODO(zhangtiff): render deltas for comment changes.
+ return html`
+ <table cellspacing="0" cellpadding="0">
+ <tbody>
+ <tr id="heading-row">
+ <th>Date</th>
+ <th>Project</th>
+ <th>Comment</th>
+ <th>Issue Link</th>
+ </tr>
+
+ ${comments && comments.length ? comments.map((comment) => html`
+ <tr id="row">
+ <td class="no-wrap">
+ <chops-timestamp
+ .timestamp=${comment.timestamp}
+ short
+ ></chops-timestamp>
+ </td>
+ <td>${comment.projectName}</td>
+ <td class="ellipsis">
+ <mr-comment-content
+ .content=${this._truncateMessage(comment.content)}
+ ></mr-comment-content>
+ </td>
+ <td class="no-wrap">
+ <a href="/p/${comment.projectName}/issues/detail?id=${comment.localId}">
+ Issue ${comment.localId}
+ </a>
+ </td>
+ </tr>
+ `) : html`
+ <tr>
+ <td colspan="4"><i>No comments.</i></td>
+ </tr>
+ `}
+ </tbody>
+ </table>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ comments: {type: Array},
+ selectedDate: {type: Number},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.comments = [];
+ }
+
+ _truncateMessage(message) {
+ return message && message.substring(0, message.indexOf('\n'));
+ }
+
+ _displayedComments(selectedDate, comments) {
+ if (!selectedDate) {
+ return comments;
+ } else {
+ const computedComments = [];
+ if (!comments) return computedComments;
+
+ for (let i = 0; i < comments.length; i++) {
+ if (comments[i].timestamp <= selectedDate &&
+ comments[i].timestamp >= (selectedDate - 86400)) {
+ computedComments.push(comments[i]);
+ }
+ }
+ return computedComments;
+ }
+ }
+}
+customElements.define('mr-comment-table', MrCommentTable);
diff --git a/static_src/elements/chdir/mr-comment-table/mr-comment-table.test.js b/static_src/elements/chdir/mr-comment-table/mr-comment-table.test.js
new file mode 100644
index 0000000..6925dc4
--- /dev/null
+++ b/static_src/elements/chdir/mr-comment-table/mr-comment-table.test.js
@@ -0,0 +1,24 @@
+// 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 {MrCommentTable} from './mr-comment-table.js';
+
+
+let element;
+
+describe('mr-comment-table', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-comment-table');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrCommentTable);
+ });
+});
diff --git a/static_src/elements/chdir/mr-profile-page/mr-profile-page.js b/static_src/elements/chdir/mr-profile-page/mr-profile-page.js
new file mode 100644
index 0000000..5fadff6
--- /dev/null
+++ b/static_src/elements/chdir/mr-profile-page/mr-profile-page.js
@@ -0,0 +1,156 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+
+import 'elements/framework/mr-header/mr-header.js';
+import '../mr-activity-table/mr-activity-table.js';
+import '../mr-comment-table/mr-comment-table.js';
+
+/**
+ * `<mr-profile-page>`
+ *
+ * The main entry point for a Monorail web components profile.
+ *
+ */
+export class MrProfilePage extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ .history-container {
+ padding: 1em 16px;
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ box-sizing: border-box;
+ flex-grow: 1;
+ }
+ mr-comment-table {
+ width: 100%;
+ margin-bottom: 1em;
+ box-sizing: border-box;
+ }
+ mr-activity-table {
+ width: 70%;
+ flex-grow: 0;
+ margin: auto;
+ margin-bottom: 5em;
+ height: 200px;
+ box-sizing: border-box;
+ }
+ .metadata-container {
+ font-size: var(--chops-main-font-size);
+ border-right: var(--chops-normal-border);
+ width: 15%;
+ min-width: 256px;
+ flex-grow: 0;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ min-height: 100%;
+ }
+ .container-outside {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 100%;
+ margin: auto;
+ padding: 0.75em 8px;
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ flex-direction: row;
+ flex-wrap: no-wrap;
+ flex-grow: 0;
+ min-height: 100%;
+ }
+ .profile-data {
+ text-align: center;
+ padding-top: 40%;
+ font-size: var(--chops-main-font-size);
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <mr-header
+ .userDisplayName=${this.user}
+ .loginUrl=${this.loginUrl}
+ .logoutUrl=${this.logoutUrl}
+ >
+ <span slot="subheader">
+ > Viewing Profile: ${this.viewedUser}
+ </span>
+ </mr-header>
+ <div class="container-outside">
+ <div class="metadata-container">
+ <div class="profile-data">
+ ${this.viewedUser} <br>
+ <b>Last visit:</b> ${this.lastVisitStr} <br>
+ <b>Starred Developers:</b>
+ ${this.starredUsers.length ? this.starredUsers.join(', ') : 'None'}
+ </div>
+ </div>
+ <div class="history-container">
+ ${this.user === this.viewedUser ? html`
+ <mr-activity-table
+ .comments=${this.comments}
+ @dateChange=${this._changeDate}
+ ></mr-activity-table>
+ `: ''}
+ <mr-comment-table
+ .user=${this.viewedUser}
+ .viewedUserId=${this.viewedUserId}
+ .comments=${this.comments}
+ .selectedDate=${this.selectedDate}>
+ </mr-comment-table>
+ </div>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ user: {type: String},
+ logoutUrl: {type: String},
+ loginUrl: {type: String},
+ viewedUser: {type: String},
+ viewedUserId: {type: Number},
+ lastVisitStr: {type: String},
+ starredUsers: {type: Array},
+ comments: {type: Array},
+ selectedDate: {type: Number},
+ };
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('viewedUserId')) {
+ this._fetchActivity();
+ }
+ }
+
+ async _fetchActivity() {
+ const commentMessage = {
+ userRef: {
+ userId: this.viewedUserId,
+ },
+ };
+
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ListActivities', commentMessage
+ );
+
+ this.comments = resp.comments;
+ }
+
+ _changeDate(e) {
+ if (!e.detail) return;
+ this.selectedDate = e.detail.date;
+ }
+}
+
+customElements.define('mr-profile-page', MrProfilePage);
diff --git a/static_src/elements/chdir/mr-profile-page/mr-profile-page.test.js b/static_src/elements/chdir/mr-profile-page/mr-profile-page.test.js
new file mode 100644
index 0000000..c967704
--- /dev/null
+++ b/static_src/elements/chdir/mr-profile-page/mr-profile-page.test.js
@@ -0,0 +1,24 @@
+// 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 {MrProfilePage} from './mr-profile-page.js';
+
+
+let element;
+
+describe('mr-profile-page', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-profile-page');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrProfilePage);
+ });
+});
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.js b/static_src/elements/chops/chops-announcement/chops-announcement.js
new file mode 100644
index 0000000..477e7d2
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.js
@@ -0,0 +1,181 @@
+// Copyright 2020 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';
+
+// URL where announcements are fetched from.
+const ANNOUNCEMENT_SERVICE =
+ 'https://chopsdash.appspot.com/prpc/dashboard.ChopsAnnouncements/SearchAnnouncements';
+
+// Prefix prepended to responses for security reasons.
+export const XSSI_PREFIX = ')]}\'';
+
+const FETCH_HEADERS = Object.freeze({
+ 'accept': 'application/json',
+ 'content-type': 'application/json',
+});
+
+// How often to refresh announcements.
+export const REFRESH_TIME_MS = 5 * 60 * 1000;
+
+/**
+ * @typedef {Object} Announcement
+ * @property {string} id
+ * @property {string} messageContent
+ */
+
+/**
+ * @typedef {Object} AnnouncementResponse
+ * @property {Array<Announcement>} announcements
+ */
+
+/**
+ * `<chops-announcement>` displays a ChopsDash message when there's an outage
+ * or other important announcement.
+ *
+ * @customElement chops-announcement
+ */
+export class ChopsAnnouncement extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ width: 100%;
+ }
+ p {
+ display: block;
+ color: #222;
+ font-size: 13px;
+ background: #FFCDD2; /* Material design red */
+ width: 100%;
+ text-align: center;
+ padding: 0.5em 16px;
+ box-sizing: border-box;
+ margin: 0;
+ /* Using a red-tinted grey border makes hues feel harmonious. */
+ border-bottom: 1px solid #D6B3B6;
+ }
+ `;
+ }
+ /** @override */
+ render() {
+ if (this._error) {
+ return html`<p><strong>Error: </strong>${this._error}</p>`;
+ }
+ return html`
+ ${this._announcements.map(
+ ({messageContent}) => html`<p>${messageContent}</p>`)}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ service: {type: String},
+ _error: {type: String},
+ _announcements: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** @type {string} */
+ this.service = undefined;
+ /** @type {string} */
+ this._error = undefined;
+ /** @type {Array<Announcement>} */
+ this._announcements = [];
+
+ /** @type {number} Interval ID returned by window.setInterval. */
+ this._interval = undefined;
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('service')) {
+ if (this.service) {
+ this.startRefresh();
+ } else {
+ this.stopRefresh();
+ }
+ }
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this.stopRefresh();
+ }
+
+ /**
+ * Set up autorefreshing logic or announcement information.
+ */
+ startRefresh() {
+ this.stopRefresh();
+ this.refresh();
+ this._interval = window.setInterval(() => this.refresh(), REFRESH_TIME_MS);
+ }
+
+ /**
+ * Logic for clearing refresh behavior.
+ */
+ stopRefresh() {
+ if (this._interval) {
+ window.clearInterval(this._interval);
+ }
+ }
+
+ /**
+ * Refresh the announcement banner.
+ */
+ async refresh() {
+ try {
+ const {announcements = []} = await this.fetch(this.service);
+ this._error = undefined;
+ this._announcements = announcements;
+ } catch (e) {
+ this._error = e.message;
+ this._announcements = [];
+ }
+ }
+
+ /**
+ * Fetches the announcement for a given service.
+ * @param {string} service Name of the service to fetch from ChopsDash.
+ * ie: "monorail"
+ * @return {Promise<AnnouncementResponse>} ChopsDash response JSON.
+ * @throws {Error} If something went wrong while fetching.
+ */
+ async fetch(service) {
+ const message = {
+ retired: false,
+ platformName: service,
+ };
+
+ const response = await window.fetch(ANNOUNCEMENT_SERVICE, {
+ method: 'POST',
+ headers: FETCH_HEADERS,
+ body: JSON.stringify(message),
+ });
+
+ if (!response.ok) {
+ throw new Error('Something went wrong while fetching announcements');
+ }
+
+ // We can't use response.json() because of the XSSI prefix.
+ const text = await response.text();
+
+ if (!text.startsWith(XSSI_PREFIX)) {
+ throw new Error(`No XSSI prefix in announce response: ${XSSI_PREFIX}`);
+ }
+
+ return JSON.parse(text.substr(XSSI_PREFIX.length));
+ }
+}
+
+customElements.define('chops-announcement', ChopsAnnouncement);
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.test.js b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
new file mode 100644
index 0000000..fa9643f
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
@@ -0,0 +1,194 @@
+// Copyright 2020 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 {ChopsAnnouncement, REFRESH_TIME_MS,
+ XSSI_PREFIX} from './chops-announcement.js';
+import sinon from 'sinon';
+
+let element;
+let clock;
+
+describe('chops-announcement', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-announcement');
+ document.body.appendChild(element);
+
+ clock = sinon.useFakeTimers({
+ now: new Date(0),
+ shouldAdvanceTime: false,
+ });
+
+ sinon.stub(window, 'fetch');
+ });
+
+ afterEach(() => {
+ if (document.body.contains(element)) {
+ document.body.removeChild(element);
+ }
+
+ clock.restore();
+
+ window.fetch.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsAnnouncement);
+ });
+
+ it('does not request announcements when no service specified', async () => {
+ sinon.stub(element, 'fetch');
+
+ element.service = '';
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetch);
+ });
+
+ it('requests announcements when service is specified', async () => {
+ sinon.stub(element, 'fetch');
+
+ element.service = 'monorail';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetch);
+ });
+
+ it('refreshes announcements regularly', async () => {
+ sinon.stub(element, 'fetch');
+
+ element.service = 'monorail';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetch);
+
+ clock.tick(REFRESH_TIME_MS);
+
+ await element.updateComplete;
+
+ sinon.assert.calledTwice(element.fetch);
+ });
+
+ it('stops refreshing when service removed', async () => {
+ sinon.stub(element, 'fetch');
+
+ element.service = 'monorail';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetch);
+
+ element.service = '';
+
+ await element.updateComplete;
+ clock.tick(REFRESH_TIME_MS);
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetch);
+ });
+
+ it('stops refreshing when element is disconnected', async () => {
+ sinon.stub(element, 'fetch');
+
+ element.service = 'monorail';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetch);
+
+ document.body.removeChild(element);
+
+ await element.updateComplete;
+ clock.tick(REFRESH_TIME_MS);
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetch);
+ });
+
+ it('renders error when thrown', async () => {
+ sinon.stub(element, 'fetch');
+ element.fetch.throws(() => Error('Something went wrong'));
+
+ element.service = 'monorail';
+
+ await element.updateComplete;
+
+ // Fetch runs here.
+
+ await element.updateComplete;
+
+ assert.equal(element._error, 'Something went wrong');
+ assert.include(element.shadowRoot.textContent, 'Something went wrong');
+ });
+
+ it('renders fetched announcement', async () => {
+ sinon.stub(element, 'fetch');
+ element.fetch.returns(
+ {announcements: [{id: '1234', messageContent: 'test thing'}]});
+
+ element.service = 'monorail';
+
+ await element.updateComplete;
+
+ // Fetch runs here.
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._announcements,
+ [{id: '1234', messageContent: 'test thing'}]);
+ assert.include(element.shadowRoot.textContent, 'test thing');
+ });
+
+ it('renders empty on empty announcement', async () => {
+ sinon.stub(element, 'fetch');
+ element.fetch.returns({});
+ element.service = 'monorail';
+
+ await element.updateComplete;
+
+ // Fetch runs here.
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._announcements, []);
+ assert.equal(0, element.shadowRoot.children.length);
+ });
+
+ it('fetch returns response data', async () => {
+ const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+ const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+ window.fetch.returns(new window.Response(fakeResponse));
+
+ const resp = await element.fetch('monorail');
+
+ assert.deepEqual(resp, json);
+ });
+
+ it('fetch errors when no XSSI prefix', async () => {
+ const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+ const fakeResponse = JSON.stringify(json);
+ window.fetch.returns(new window.Response(fakeResponse));
+
+ try {
+ await element.fetch('monorail');
+ } catch (e) {
+ assert.include(e.message, 'No XSSI prefix in announce response:');
+ }
+ });
+
+ it('fetch errors when response is not okay', async () => {
+ const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+ const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+ window.fetch.returns(new window.Response(fakeResponse, {status: 500}));
+
+ try {
+ await element.fetch('monorail');
+ } catch (e) {
+ assert.include(e.message,
+ 'Something went wrong while fetching announcements');
+ }
+ });
+});
diff --git a/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
new file mode 100644
index 0000000..dab8f85
--- /dev/null
+++ b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
@@ -0,0 +1,632 @@
+// 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} from 'lit-element';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+
+/**
+ * @type {RegExp} Autocomplete options are matched at word boundaries. This
+ * Regex specifies what counts as a boundary between words.
+ */
+const DELIMITER_REGEX = /[^a-z0-9]+/i;
+
+/**
+ * Specifies what happens to the input element an autocomplete
+ * instance is attached to when a user selects an autocomplete option. This
+ * constant specifies the default behavior where a form's entire value is
+ * replaced with the selected value.
+ * @param {HTMLInputElement} input An input element.
+ * @param {string} value The value of the selected autocomplete option.
+ */
+const DEFAULT_REPLACER = (input, value) => {
+ input.value = value;
+};
+
+/**
+ * @type {number} The default maximum of completions to render at a time.
+ */
+const DEFAULT_MAX_COMPLETIONS = 200;
+
+/**
+ * @type {number} Globally shared counter for autocomplete instances to help
+ * ensure that no two <chops-autocomplete> options have the same ID.
+ */
+let idCount = 1;
+
+/**
+ * `<chops-autocomplete>` shared autocomplete UI code that inter-ops with
+ * other code.
+ *
+ * chops-autocomplete inter-ops with any input element, whether custom or
+ * native that can receive change handlers and has a 'value' property which
+ * can be read and set.
+ *
+ * NOTE: This element disables ShadowDOM for accessibility reasons: to allow
+ * aria attributes from the outside to reference features in this element.
+ *
+ * @customElement chops-autocomplete
+ */
+export class ChopsAutocomplete extends LitElement {
+ /** @override */
+ render() {
+ const completions = this.completions;
+ const currentValue = this._prefix.trim().toLowerCase();
+ const index = this._selectedIndex;
+ const currentCompletion = index >= 0 &&
+ index < completions.length ? completions[index] : '';
+
+ return html`
+ <style>
+ /*
+ * Really specific class names are necessary because ShadowDOM
+ * is disabled for this component.
+ */
+ .chops-autocomplete-container {
+ position: relative;
+ }
+ .chops-autocomplete-container table {
+ padding: 0;
+ font-size: var(--chops-main-font-size);
+ color: var(--chops-link-color);
+ position: absolute;
+ background: var(--chops-white);
+ border: var(--chops-accessible-border);
+ z-index: 999;
+ box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
+ border-spacing: 0;
+ border-collapse: collapse;
+ /* In the case when the autocomplete extends the
+ * height of the viewport, we want to make sure
+ * there's spacing. */
+ margin-bottom: 1em;
+ }
+ .chops-autocomplete-container tbody {
+ display: block;
+ min-width: 100px;
+ max-height: 500px;
+ overflow: auto;
+ }
+ .chops-autocomplete-container tr {
+ cursor: pointer;
+ transition: background 0.2s ease-in-out;
+ }
+ .chops-autocomplete-container tr[data-selected] {
+ background: var(--chops-active-choice-bg);
+ text-decoration: underline;
+ }
+ .chops-autocomplete-container td {
+ padding: 0.25em 8px;
+ white-space: nowrap;
+ }
+ .screenreader-hidden {
+ clip: rect(1px, 1px, 1px, 1px);
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+ }
+ </style>
+ <div class="chops-autocomplete-container">
+ <span class="screenreader-hidden" aria-live="polite">
+ ${currentCompletion}
+ </span>
+ <table
+ ?hidden=${!completions.length}
+ >
+ <tbody>
+ ${completions.map((completion, i) => html`
+ <tr
+ id=${completionId(this.id, i)}
+ ?data-selected=${i === index}
+ data-index=${i}
+ data-value=${completion}
+ @mouseover=${this._hoverCompletion}
+ @mousedown=${this._clickCompletion}
+ role="option"
+ aria-selected=${completion.toLowerCase() ===
+ currentValue ? 'true' : 'false'}
+ >
+ <td class="completion">
+ ${this._renderCompletion(completion)}
+ </td>
+ <td class="docstring">
+ ${this._renderDocstring(completion)}
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ </div>
+ `;
+ }
+
+ /**
+ * Renders a single autocomplete result.
+ * @param {string} completion The string for the currently selected
+ * autocomplete value.
+ * @return {TemplateResult}
+ */
+ _renderCompletion(completion) {
+ const matchDict = this._matchDict;
+
+ if (!(completion in matchDict)) return completion;
+
+ const {index, matchesDoc} = matchDict[completion];
+
+ if (matchesDoc) return completion;
+
+ const prefix = this._prefix;
+ const start = completion.substr(0, index);
+ const middle = completion.substr(index, prefix.length);
+ const end = completion.substr(index + prefix.length);
+
+ return html`${start}<b>${middle}</b>${end}`;
+ }
+
+ /**
+ * Finds the docstring for a given autocomplete result and renders it.
+ * @param {string} completion The autocomplete result rendered.
+ * @return {TemplateResult}
+ */
+ _renderDocstring(completion) {
+ const matchDict = this._matchDict;
+ const docDict = this.docDict;
+
+ if (!completion in docDict) return '';
+
+ const doc = docDict[completion];
+
+ if (!(completion in matchDict)) return doc;
+
+ const {index, matchesDoc} = matchDict[completion];
+
+ if (!matchesDoc) return doc;
+
+ const prefix = this._prefix;
+ const start = doc.substr(0, index);
+ const middle = doc.substr(index, prefix.length);
+ const end = doc.substr(index + prefix.length);
+
+ return html`${start}<b>${middle}</b>${end}`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * The input this element is for.
+ */
+ for: {type: String},
+ /**
+ * Generated id for the element.
+ */
+ id: {
+ type: String,
+ reflect: true,
+ },
+ /**
+ * The role attribute, set for accessibility.
+ */
+ role: {
+ type: String,
+ reflect: true,
+ },
+ /**
+ * Array of strings for possible autocompletion values.
+ */
+ strings: {type: Array},
+ /**
+ * A dictionary containing optional doc strings for each autocomplete
+ * string.
+ */
+ docDict: {type: Object},
+ /**
+ * An optional function to compute what happens when the user selects
+ * a value.
+ */
+ replacer: {type: Object},
+ /**
+ * An Array of the currently suggested autcomplte values.
+ */
+ completions: {type: Array},
+ /**
+ * Maximum number of completion values that can display at once.
+ */
+ max: {type: Number},
+ /**
+ * Dict of locations of matched substrings. Value format:
+ * {index, matchesDoc}.
+ */
+ _matchDict: {type: Object},
+ _selectedIndex: {type: Number},
+ _prefix: {type: String},
+ _forRef: {type: Object},
+ _boundToggleCompletionsOnFocus: {type: Object},
+ _boundNavigateCompletions: {type: Object},
+ _boundUpdateCompletions: {type: Object},
+ _oldAttributes: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.strings = [];
+ this.docDict = {};
+ this.completions = [];
+ this.max = DEFAULT_MAX_COMPLETIONS;
+
+ this.role = 'listbox';
+ this.id = `chops-autocomplete-${idCount++}`;
+
+ this._matchDict = {};
+ this._selectedIndex = -1;
+ this._prefix = '';
+ this._boundToggleCompletionsOnFocus =
+ this._toggleCompletionsOnFocus.bind(this);
+ this._boundUpdateCompletions = this._updateCompletions.bind(this);
+ this._boundNavigateCompletions = this._navigateCompletions.bind(this);
+ this._oldAttributes = {};
+ }
+
+ // Disable shadow DOM to allow aria attributes to propagate.
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this._disconnectAutocomplete(this._forRef);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('for')) {
+ const forRef = this.getRootNode().querySelector('#' + this.for);
+
+ // TODO(zhangtiff): Make this element work with custom input components
+ // in the future as well.
+ this._forRef = (forRef.tagName || '').toUpperCase() === 'INPUT' ?
+ forRef : undefined;
+ this._connectAutocomplete(this._forRef);
+ }
+ if (this._forRef) {
+ if (changedProperties.has('id')) {
+ this._forRef.setAttribute('aria-owns', this.id);
+ }
+ if (changedProperties.has('completions')) {
+ // a11y. Tell screenreaders whether the autocomplete is expanded.
+ this._forRef.setAttribute('aria-expanded',
+ this.completions.length ? 'true' : 'false');
+ }
+
+ if (changedProperties.has('_selectedIndex') ||
+ changedProperties.has('completions')) {
+ this._updateAriaActiveDescendant(this._forRef);
+
+ this._scrollCompletionIntoView(this._selectedIndex);
+ }
+ }
+ }
+
+ /**
+ * Sets the aria-activedescendant attribute of the element (ie: an input form)
+ * that the autocomplete is attached to, in order to tell screenreaders about
+ * which autocomplete option is currently selected.
+ * @param {HTMLInputElement} element
+ */
+ _updateAriaActiveDescendant(element) {
+ const i = this._selectedIndex;
+
+ if (i >= 0 && i < this.completions.length) {
+ const selectedId = completionId(this.id, i);
+
+ // a11y. Set the ID of the currently selected element.
+ element.setAttribute('aria-activedescendant', selectedId);
+
+ // Scroll the container to make sure the selected element is in view.
+ } else {
+ element.setAttribute('aria-activedescendant', '');
+ }
+ }
+
+ /**
+ * When a user moves up or down from an autocomplete option that's at the top
+ * or bottom of the autocomplete option container, we must scroll the
+ * container to make sure the user always sees the option they've selected.
+ * @param {number} i The index of the autocomplete option to put into view.
+ */
+ _scrollCompletionIntoView(i) {
+ const selectedId = completionId(this.id, i);
+
+ const container = this.querySelector('tbody');
+ const completion = this.querySelector(`#${selectedId}`);
+
+ if (!completion) return;
+
+ const distanceFromTop = completion.offsetTop - container.scrollTop;
+
+ // If the completion is above the viewport for the container.
+ if (distanceFromTop < 0) {
+ // Position the completion at the top of the container.
+ container.scrollTop = completion.offsetTop;
+ }
+
+ // If the compltion is below the viewport for the container.
+ if (distanceFromTop > (container.offsetHeight - completion.offsetHeight)) {
+ // Position the compltion at the bottom of the container.
+ container.scrollTop = completion.offsetTop - (container.offsetHeight -
+ completion.offsetHeight);
+ }
+ }
+
+ /**
+ * Changes the input's value according to the rules of the replacer function.
+ * @param {string} value - the value to swap in.
+ * @return {undefined}
+ */
+ completeValue(value) {
+ if (!this._forRef) return;
+
+ const replacer = this.replacer || DEFAULT_REPLACER;
+ replacer(this._forRef, value);
+
+ this.hideCompletions();
+ }
+
+ /**
+ * Computes autocomplete values matching the current input in the field.
+ * @return {boolean} Whether any completions were found.
+ */
+ showCompletions() {
+ if (!this._forRef) {
+ this.hideCompletions();
+ return false;
+ }
+ this._prefix = this._forRef.value.trim().toLowerCase();
+ // Always select the first completion by default when recomputing
+ // completions.
+ this._selectedIndex = 0;
+
+ const matchDict = {};
+ const accepted = [];
+ matchDict;
+ for (let i = 0; i < this.strings.length &&
+ accepted.length < this.max; i++) {
+ const s = this.strings[i];
+ let matchIndex = this._matchIndex(this._prefix, s);
+ let matches = matchIndex >= 0;
+ if (matches) {
+ matchDict[s] = {index: matchIndex, matchesDoc: false};
+ } else if (s in this.docDict) {
+ matchIndex = this._matchIndex(this._prefix, this.docDict[s]);
+ matches = matchIndex >= 0;
+ if (matches) {
+ matchDict[s] = {index: matchIndex, matchesDoc: true};
+ }
+ }
+ if (matches) {
+ accepted.push(s);
+ }
+ }
+
+ this._matchDict = matchDict;
+
+ this.completions = accepted;
+
+ return !!this.completions.length;
+ }
+
+ /**
+ * Finds where a given user input matches an autocomplete option. Note that
+ * a match is only found if the substring is at either the beginning of the
+ * string or the beginning of a delimited section of the string. Hence, we
+ * refer to the "needle" in this function a "prefix".
+ * @param {string} prefix The value that the user inputed into the form.
+ * @param {string} s The autocomplete option that's being compared.
+ * @return {number} An integer for what index the substring is found in the
+ * autocomplete option. Returns -1 if no match.
+ */
+ _matchIndex(prefix, s) {
+ const matchStart = s.toLowerCase().indexOf(prefix.toLocaleLowerCase());
+ if (matchStart === 0 ||
+ (matchStart > 0 && s[matchStart - 1].match(DELIMITER_REGEX))) {
+ return matchStart;
+ }
+ return -1;
+ }
+
+ /**
+ * Hides autocomplete options.
+ */
+ hideCompletions() {
+ this.completions = [];
+ this._prefix = '';
+ this._selectedIndex = -1;
+ }
+
+ /**
+ * Sets an autocomplete option that a user hovers over as the selected option.
+ * @param {MouseEvent} e
+ */
+ _hoverCompletion(e) {
+ const target = e.currentTarget;
+
+ if (!target.dataset || !target.dataset.index) return;
+
+ const index = Number.parseInt(target.dataset.index);
+ if (index >= 0 && index < this.completions.length) {
+ this._selectedIndex = index;
+ }
+ }
+
+ /**
+ * Sets the value of the form input that the user is editing to the
+ * autocomplete option that the user just clicked.
+ * @param {MouseEvent} e
+ */
+ _clickCompletion(e) {
+ e.preventDefault();
+ const target = e.currentTarget;
+ if (!target.dataset || !target.dataset.value) return;
+
+ this.completeValue(target.dataset.value);
+ }
+
+ /**
+ * Hides and shows the autocomplete completions when a user focuses and
+ * unfocuses a form.
+ * @param {FocusEvent} e
+ */
+ _toggleCompletionsOnFocus(e) {
+ const target = e.target;
+
+ // Check if the input is focused or not.
+ if (target.matches(':focus')) {
+ this.showCompletions();
+ } else {
+ this.hideCompletions();
+ }
+ }
+
+ /**
+ * Implements hotkeys to allow the user to navigate autocomplete options with
+ * their keyboard. ie: pressing up and down to select options or Esc to close
+ * the form.
+ * @param {KeyboardEvent} e
+ */
+ _navigateCompletions(e) {
+ const completions = this.completions;
+ if (!completions.length) return;
+
+ switch (e.key) {
+ // TODO(zhangtiff): Throttle or control keyboard navigation so the user
+ // can't navigate faster than they can can perceive.
+ case 'ArrowUp':
+ e.preventDefault();
+ this._navigateUp();
+ break;
+ case 'ArrowDown':
+ e.preventDefault();
+ this._navigateDown();
+ break;
+ case 'Enter':
+ // TODO(zhangtiff): Add Tab to this case as well once all issue detail
+ // inputs use chops-autocomplete.
+ e.preventDefault();
+ if (this._selectedIndex >= 0 &&
+ this._selectedIndex <= completions.length) {
+ this.completeValue(completions[this._selectedIndex]);
+ }
+ break;
+ case 'Escape':
+ e.preventDefault();
+ this.hideCompletions();
+ break;
+ }
+ }
+
+ /**
+ * Selects the completion option above the current one.
+ */
+ _navigateUp() {
+ const completions = this.completions;
+ this._selectedIndex -= 1;
+ if (this._selectedIndex < 0) {
+ this._selectedIndex = completions.length - 1;
+ }
+ }
+
+ /**
+ * Selects the completion option below the current one.
+ */
+ _navigateDown() {
+ const completions = this.completions;
+ this._selectedIndex += 1;
+ if (this._selectedIndex >= completions.length) {
+ this._selectedIndex = 0;
+ }
+ }
+
+ /**
+ * Recomputes autocomplete completions when the user types a new input.
+ * Ignores KeyboardEvents that don't change the input value of the form
+ * to prevent excess recomputations.
+ * @param {KeyboardEvent} e
+ */
+ _updateCompletions(e) {
+ if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+ this.showCompletions();
+ }
+
+ /**
+ * Initializes the input element that this autocomplete instance is
+ * attached to with aria attributes required for accessibility.
+ * @param {HTMLInputElement} node The input element that the autocomplete is
+ * attached to.
+ */
+ _connectAutocomplete(node) {
+ if (!node) return;
+
+ node.addEventListener('keyup', this._boundUpdateCompletions);
+ node.addEventListener('keydown', this._boundNavigateCompletions);
+ node.addEventListener('focus', this._boundToggleCompletionsOnFocus);
+ node.addEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+ this._oldAttributes = {
+ 'aria-owns': node.getAttribute('aria-owns'),
+ 'aria-autocomplete': node.getAttribute('aria-autocomplete'),
+ 'aria-expanded': node.getAttribute('aria-expanded'),
+ 'aria-haspopup': node.getAttribute('aria-haspopup'),
+ 'aria-activedescendant': node.getAttribute('aria-activedescendant'),
+ };
+ node.setAttribute('aria-owns', this.id);
+ node.setAttribute('aria-autocomplete', 'both');
+ node.setAttribute('aria-expanded', 'false');
+ node.setAttribute('aria-haspopup', 'listbox');
+ node.setAttribute('aria-activedescendant', '');
+ }
+
+ /**
+ * When <chops-autocomplete> is disconnected or moved to a difference form,
+ * this function removes the side effects added by <chops-autocomplete> on the
+ * input element that <chops-autocomplete> is attached to.
+ * @param {HTMLInputElement} node The input element that the autocomplete is
+ * attached to.
+ */
+ _disconnectAutocomplete(node) {
+ if (!node) return;
+
+ node.removeEventListener('keyup', this._boundUpdateCompletions);
+ node.removeEventListener('keydown', this._boundNavigateCompletions);
+ node.removeEventListener('focus', this._boundToggleCompletionsOnFocus);
+ node.removeEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+ for (const key of Object.keys(this._oldAttributes)) {
+ node.setAttribute(key, this._oldAttributes[key]);
+ }
+ this._oldAttributes = {};
+ }
+}
+
+/**
+ * Generates a unique HTML ID for a given autocomplete option, for use by
+ * aria-activedescendant. Note that because the autocomplete element has
+ * ShadowDOM disabled, we need to make sure the ID is specific enough to be
+ * globally unique across the entire application.
+ * @param {string} prefix A unique prefix to differentiate this autocomplete
+ * instance from other autocomplete instances.
+ * @param {number} i The index of the autocomplete option.
+ * @return {string} A unique HTML ID for a given autocomplete option.
+ */
+function completionId(prefix, i) {
+ return `${prefix}-option-${i}`;
+}
+
+customElements.define('chops-autocomplete', ChopsAutocomplete);
diff --git a/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js
new file mode 100644
index 0000000..e470312
--- /dev/null
+++ b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js
@@ -0,0 +1,358 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {ChopsAutocomplete} from './chops-autocomplete.js';
+
+let element;
+let input;
+
+describe('chops-autocomplete', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-autocomplete');
+ document.body.appendChild(element);
+
+ input = document.createElement('input');
+ input.id = 'autocomplete-input';
+ document.body.appendChild(input);
+
+ element.for = 'autocomplete-input';
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ document.body.removeChild(input);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsAutocomplete);
+ });
+
+ it('registers child input', async () => {
+ await element.updateComplete;
+
+ assert.isNotNull(element._forRef);
+ assert.equal(element._forRef.tagName.toUpperCase(), 'INPUT');
+ });
+
+ it('completeValue sets input value', async () => {
+ await element.updateComplete;
+
+ element.completeValue('test');
+ assert.equal(input.value, 'test');
+
+ element.completeValue('again');
+ assert.equal(input.value, 'again');
+ });
+
+ it('completeValue can run a custom replacer', async () => {
+ element.replacer = (input, value) => input.value = value + ',';
+ await element.updateComplete;
+
+ element.completeValue('trailing');
+ assert.equal(input.value, 'trailing,');
+
+ element.completeValue('comma');
+ assert.equal(input.value, 'comma,');
+ });
+
+ it('completions render', async () => {
+ element.completions = ['hello', 'world'];
+ element.docDict = {'hello': 'well hello there'};
+ await element.updateComplete;
+
+ const completions = element.querySelectorAll('.completion');
+ const docstrings = element.querySelectorAll('.docstring');
+
+ assert.equal(completions.length, 2);
+ assert.equal(docstrings.length, 2);
+
+ assert.include(completions[0].textContent, 'hello');
+ assert.include(completions[1].textContent, 'world');
+
+ assert.include(docstrings[0].textContent, 'well hello there');
+ assert.include(docstrings[1].textContent, '');
+ });
+
+ it('completions bold matched section when rendering', async () => {
+ element.completions = ['hello-world'];
+ element._prefix = 'wor';
+ element._matchDict = {
+ 'hello-world': {'index': 6},
+ };
+
+ await element.updateComplete;
+
+ const completion = element.querySelector('.completion');
+
+ assert.include(completion.textContent, 'hello-world');
+
+ assert.equal(completion.querySelector('b').textContent.trim(), 'wor');
+ });
+
+
+ it('showCompletions populates completions with matches', async () => {
+ element.strings = [
+ 'test-one',
+ 'test-two',
+ 'ignore',
+ 'hello',
+ 'woah-test',
+ 'i-am-a-tester',
+ ];
+ input.value = 'test';
+ await element.updateComplete;
+
+ element.showCompletions();
+
+ assert.deepEqual(element.completions, [
+ 'test-one',
+ 'test-two',
+ 'woah-test',
+ 'i-am-a-tester',
+ ]);
+ });
+
+ it('showCompletions matches docs', async () => {
+ element.strings = [
+ 'hello',
+ 'world',
+ 'no-op',
+ ];
+ element.docDict = {'world': 'this is a test'};
+ input.value = 'test';
+ await element.updateComplete;
+
+ element.showCompletions();
+
+ assert.deepEqual(element.completions, [
+ 'world',
+ ]);
+ });
+
+ it('showCompletions caps completions at max', async () => {
+ element.max = 2;
+ element.strings = [
+ 'test-one',
+ 'test-two',
+ 'ignore',
+ 'hello',
+ 'woah-test',
+ 'i-am-a-tester',
+ ];
+ input.value = 'test';
+ await element.updateComplete;
+
+ element.showCompletions();
+
+ assert.deepEqual(element.completions, [
+ 'test-one',
+ 'test-two',
+ ]);
+ });
+
+ it('hideCompletions hides completions', async () => {
+ element.completions = [
+ 'test-one',
+ 'test-two',
+ ];
+
+ await element.updateComplete;
+
+ const completionTable = element.querySelector('table');
+ assert.isFalse(completionTable.hidden);
+
+ element.hideCompletions();
+
+ await element.updateComplete;
+
+ assert.isTrue(completionTable.hidden);
+ });
+
+ it('clicking completion completes it', async () => {
+ element.completions = [
+ 'test-one',
+ 'test-two',
+ 'click me!',
+ 'test',
+ ];
+
+ await element.updateComplete;
+
+ const completions = element.querySelectorAll('tr');
+
+ assert.equal(input.value, '');
+
+ // Note: the click() event can only trigger click events, not mousedown
+ // events, so we are instead manually running the event handler.
+ element._clickCompletion({
+ preventDefault: sinon.stub(),
+ currentTarget: completions[2],
+ });
+
+ assert.equal(input.value, 'click me!');
+ });
+
+ it('completion is scrolled into view when outside viewport', async () => {
+ element.completions = [
+ 'i',
+ 'am',
+ 'an option',
+ ];
+ element._selectedIndex = 0;
+ element.id = 'chops-autocomplete-1';
+
+ await element.updateComplete;
+
+ const container = element.querySelector('tbody');
+ const completion = container.querySelector('tr');
+ const completionHeight = completion.offsetHeight;
+ // Make the table one row tall.
+ container.style.height = `${completionHeight}px`;
+
+ element._selectedIndex = 1;
+ await element.updateComplete;
+
+ assert.equal(container.scrollTop, completionHeight);
+
+ element._selectedIndex = 2;
+ await element.updateComplete;
+
+ assert.equal(container.scrollTop, completionHeight * 2);
+
+ element._selectedIndex = 0;
+ await element.updateComplete;
+
+ assert.equal(container.scrollTop, 0);
+ });
+
+ it('aria-activedescendant set based on selected option', async () => {
+ element.completions = [
+ 'i',
+ 'am',
+ 'an option',
+ ];
+ element._selectedIndex = 1;
+ element.id = 'chops-autocomplete-1';
+
+ await element.updateComplete;
+
+ assert.equal(input.getAttribute('aria-activedescendant'),
+ 'chops-autocomplete-1-option-1');
+ });
+
+ it('hovering over a completion selects it', async () => {
+ element.completions = [
+ 'hover',
+ 'over',
+ 'me',
+ ];
+
+ await element.updateComplete;
+
+ const completions = element.querySelectorAll('tr');
+
+ element._hoverCompletion({
+ currentTarget: completions[2],
+ });
+
+ assert.equal(element._selectedIndex, 2);
+
+ element._hoverCompletion({
+ currentTarget: completions[1],
+ });
+
+ assert.equal(element._selectedIndex, 1);
+ });
+
+ it('ArrowDown moves through completions', async () => {
+ element.completions = [
+ 'move',
+ 'down',
+ 'me',
+ ];
+
+ element._selectedIndex = 0;
+
+ await element.updateComplete;
+
+ const preventDefault = sinon.stub();
+
+ element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+ assert.equal(element._selectedIndex, 1);
+
+ element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+ assert.equal(element._selectedIndex, 2);
+
+ // Wrap around.
+ element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+ assert.equal(element._selectedIndex, 0);
+
+ sinon.assert.callCount(preventDefault, 3);
+ });
+
+ it('ArrowUp moves through completions', async () => {
+ element.completions = [
+ 'move',
+ 'up',
+ 'me',
+ ];
+
+ element._selectedIndex = 0;
+
+ await element.updateComplete;
+
+ const preventDefault = sinon.stub();
+
+ // Wrap around.
+ element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+ assert.equal(element._selectedIndex, 2);
+
+ element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+ assert.equal(element._selectedIndex, 1);
+
+ element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+ assert.equal(element._selectedIndex, 0);
+
+ sinon.assert.callCount(preventDefault, 3);
+ });
+
+ it('Enter completes with selected completion', async () => {
+ element.completions = [
+ 'hello',
+ 'pick me',
+ 'world',
+ ];
+
+ element._selectedIndex = 1;
+
+ await element.updateComplete;
+
+ const preventDefault = sinon.stub();
+
+ element._navigateCompletions({preventDefault, key: 'Enter'});
+
+ assert.equal(input.value, 'pick me');
+ sinon.assert.callCount(preventDefault, 1);
+ });
+
+ it('Escape hides completions', async () => {
+ element.completions = [
+ 'hide',
+ 'me',
+ ];
+
+ await element.updateComplete;
+
+ const preventDefault = sinon.stub();
+ element._navigateCompletions({preventDefault, key: 'Escape'});
+
+ sinon.assert.callCount(preventDefault, 1);
+
+ await element.updateComplete;
+
+ assert.equal(element.completions.length, 0);
+ });
+});
diff --git a/static_src/elements/chops/chops-button/chops-button.js b/static_src/elements/chops/chops-button/chops-button.js
new file mode 100644
index 0000000..2139e22
--- /dev/null
+++ b/static_src/elements/chops/chops-button/chops-button.js
@@ -0,0 +1,112 @@
+// 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';
+
+/**
+ * `<chops-button>` displays a styled button component with a few niceties.
+ *
+ * @customElement chops-button
+ * @demo /demo/chops-button_demo.html
+ */
+export class ChopsButton extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ --chops-button-padding: 0.5em 16px;
+ background: hsla(0, 0%, 95%, 1);
+ margin: 0.25em 4px;
+ cursor: pointer;
+ border-radius: 3px;
+ text-align: center;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ user-select: none;
+ transition: filter 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
+ font-family: var(--chops-font-family);
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ :host([raised]) {
+ box-shadow: 0px 2px 8px -1px hsla(0, 0%, 0%, 0.5);
+ }
+ :host(:hover) {
+ filter: brightness(95%);
+ }
+ :host(:active) {
+ filter: brightness(115%);
+ }
+ :host([raised]:active) {
+ box-shadow: 0px 1px 8px -1px hsla(0, 0%, 0%, 0.5);
+ }
+ :host([disabled]),
+ :host([disabled]:hover) {
+ filter: grayscale(30%);
+ opacity: 0.4;
+ background: hsla(0, 0%, 87%, 1);
+ cursor: default;
+ pointer-events: none;
+ box-shadow: none;
+ }
+ button {
+ background: none;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ padding: var(--chops-button-padding);
+ margin: 0;
+ color: inherit;
+ cursor: inherit;
+ text-align: center;
+ font-family: inherit;
+ text-align: inherit;
+ font-weight: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ border-radius: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <button ?disabled=${this.disabled}>
+ <slot></slot>
+ </button>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /** Whether the button is available for input or not. */
+ disabled: {
+ type: Boolean,
+ reflect: true,
+ },
+ /** Whether the button should have a shadow or not. */
+ raised: {
+ type: Boolean,
+ value: false,
+ reflect: true,
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.disabled = false;
+ this.raised = false;
+ }
+}
+customElements.define('chops-button', ChopsButton);
diff --git a/static_src/elements/chops/chops-button/chops-button.test.js b/static_src/elements/chops/chops-button/chops-button.test.js
new file mode 100644
index 0000000..4487564
--- /dev/null
+++ b/static_src/elements/chops/chops-button/chops-button.test.js
@@ -0,0 +1,45 @@
+// 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 {ChopsButton} from './chops-button.js';
+import {auditA11y} from 'shared/test/helpers';
+
+let element;
+
+describe('chops-button', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-button');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsButton);
+ });
+
+ it('initial a11y', async () => {
+ const text = document.createTextNode('button text');
+ element.appendChild(text);
+ await auditA11y(element);
+ });
+
+ it('chops-button can be disabled', async () => {
+ await element.updateComplete;
+
+ const innerButton = element.shadowRoot.querySelector('button');
+
+ assert.isFalse(element.hasAttribute('disabled'));
+ assert.isFalse(innerButton.hasAttribute('disabled'));
+
+ element.disabled = true;
+ await element.updateComplete;
+
+ assert.isTrue(element.hasAttribute('disabled'));
+ assert.isTrue(innerButton.hasAttribute('disabled'));
+ });
+});
diff --git a/static_src/elements/chops/chops-checkbox/chops-checkbox.js b/static_src/elements/chops/chops-checkbox/chops-checkbox.js
new file mode 100644
index 0000000..d752347
--- /dev/null
+++ b/static_src/elements/chops/chops-checkbox/chops-checkbox.js
@@ -0,0 +1,135 @@
+// 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';
+
+/**
+ * `<chops-checkbox>`
+ *
+ * A checkbox component. This component is primarily a wrapper
+ * around a native checkbox to allow easy sharing of styles.
+ *
+ */
+export class ChopsCheckbox extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ --chops-checkbox-color: var(--chops-primary-accent-color);
+ /* A bit brighter than Chrome's default focus color to
+ * avoid blending into the checkbox's blue. */
+ --chops-checkbox-focus-color: hsl(193, 82%, 63%);
+ --chops-checkbox-size: 16px;
+ --chops-checkbox-check-size: 18px;
+ }
+ label {
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ }
+ input[type="checkbox"] {
+ /* We need the checkbox to be hidden but still accessible. */
+ opacity: 0;
+ width: 0;
+ height: 0;
+ position: absolute;
+ top: -9999;
+ left: -9999;
+ }
+ label::before {
+ width: var(--chops-checkbox-size);
+ height: var(--chops-checkbox-size);
+ margin-right: 8px;
+ box-sizing: border-box;
+ content: "\\2713";
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid #222;
+ border-radius: 2px;
+ background: #fff;
+ font-size: var(--chops-checkbox-check-size);
+ padding: 0;
+ color: transparent;
+ }
+ input[type="checkbox"]:focus + label::before {
+ /* Make sure an outline shows around this element for
+ * accessibility.
+ */
+ box-shadow: 0 0 5px 1px var(--chops-checkbox-focus-color);
+ }
+ input[type="checkbox"]:checked + label::before {
+ background: var(--chops-checkbox-color);
+ border-color: var(--chops-checkbox-color);
+ color: #fff;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <!-- Note: Avoiding 2-way data binding to futureproof this code
+ for LitElement. -->
+ <input id="checkbox" type="checkbox"
+ .checked=${this.checked} @change=${this._checkedChangeHandler}>
+ <label for="checkbox">
+ <slot></slot>
+ </label>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ label: {type: String},
+
+ /**
+ * Note: At the moment, this component does not manage its own
+ * internal checked state. It expects its checked state to come
+ * from its parent, and its parent is expected to update the
+ * chops-checkbox's checked state on a change event.
+ *
+ * This can be generalized in the future to support multiple
+ * ways of managing checked state if needed.
+ **/
+ checked: {type: Boolean},
+ };
+ }
+
+ /**
+ * Clicks the checkbox. Helpful for automated testing.
+ */
+ click() {
+ super.click();
+ /** @type {HTMLInputElement} */ (
+ this.shadowRoot.querySelector('#checkbox')).click();
+ }
+
+ /**
+ * Listens to the native checkbox's change event and runs internal
+ * logic based on changes.
+ * @param {Event} evt
+ * @private
+ */
+ _checkedChangeHandler(evt) {
+ this._checkedChange(evt.target.checked);
+ }
+
+ /**
+ * @param {boolean} checked Whether the box was checked or unchecked.
+ * @fires CustomEvent#checked-change
+ * @private
+ */
+ _checkedChange(checked) {
+ if (checked === this.checked) return;
+ const customEvent = new CustomEvent('checked-change', {
+ detail: {
+ checked: checked,
+ },
+ });
+ this.dispatchEvent(customEvent);
+ }
+}
+customElements.define('chops-checkbox', ChopsCheckbox);
diff --git a/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js b/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js
new file mode 100644
index 0000000..5a11111
--- /dev/null
+++ b/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js
@@ -0,0 +1,86 @@
+// 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 {ChopsCheckbox} from './chops-checkbox.js';
+
+let element;
+
+describe('chops-checkbox', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-checkbox');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsCheckbox);
+ });
+
+ it('clicking checkbox dispatches checked-change event', async () => {
+ element.checked = false;
+ sinon.stub(window, 'CustomEvent');
+ sinon.stub(element, 'dispatchEvent');
+
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('#checkbox').click();
+
+ assert.deepEqual(window.CustomEvent.args[0][0], 'checked-change');
+ assert.deepEqual(window.CustomEvent.args[0][1], {
+ detail: {checked: true},
+ });
+
+ assert.isTrue(window.CustomEvent.calledOnce);
+ assert.isTrue(element.dispatchEvent.calledOnce);
+
+ window.CustomEvent.restore();
+ element.dispatchEvent.restore();
+ });
+
+ it('updating checked property updates native <input>', async () => {
+ element.checked = false;
+
+ await element.updateComplete;
+
+ assert.isFalse(element.checked);
+ assert.isFalse(element.shadowRoot.querySelector('input').checked);
+
+ element.checked = true;
+
+ await element.updateComplete;
+
+ assert.isTrue(element.checked);
+ assert.isTrue(element.shadowRoot.querySelector('input').checked);
+ });
+
+ it('updating checked attribute updates native <input>', async () => {
+ element.setAttribute('checked', true);
+ await element.updateComplete;
+
+ assert.equal(element.getAttribute('checked'), 'true');
+ assert.isTrue(element.shadowRoot.querySelector('input').checked);
+
+ element.click();
+ await element.updateComplete;
+
+ // We expect the 'checked' attribute to remain the same even as the
+ // corresponding property changes when the user clicks the checkbox.
+ assert.equal(element.getAttribute('checked'), 'true');
+ assert.isFalse(element.shadowRoot.querySelector('input').checked);
+
+ element.click();
+ await element.updateComplete;
+ assert.isTrue(element.shadowRoot.querySelector('input').checked);
+
+ element.removeAttribute('checked');
+ await element.updateComplete;
+ assert.isNotTrue(element.getAttribute('checked'));
+ assert.isFalse(element.shadowRoot.querySelector('input').checked);
+ });
+});
diff --git a/static_src/elements/chops/chops-chip/chops-chip.js b/static_src/elements/chops/chops-chip/chops-chip.js
new file mode 100644
index 0000000..ce8319e
--- /dev/null
+++ b/static_src/elements/chops/chops-chip/chops-chip.js
@@ -0,0 +1,122 @@
+// 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';
+
+/**
+ * `<chops-chip>` displays a chip.
+ * "Chips are compact elements that represent an input, attribute, or action."
+ * https://material.io/components/chips/
+ */
+export class ChopsChip extends LitElement {
+ /** @override */
+ static get properties() {
+ return {
+ focusable: {type: Boolean, reflect: true},
+ thumbnail: {type: String},
+ buttonIcon: {type: String},
+ buttonLabel: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** @type {boolean} */
+ this.focusable = false;
+
+ /** @type {string} */
+ this.thumbnail = '';
+
+ /** @type {string} */
+ this.buttonIcon = '';
+ /** @type {string} */
+ this.buttonLabel = '';
+ }
+
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ --chops-chip-bg-color: var(--chops-blue-gray-50);
+ display: inline-flex;
+ padding: 0px 10px;
+ line-height: 22px;
+ margin: 0 2px;
+ border-radius: 12px;
+ background: var(--chops-chip-bg-color);
+ align-items: center;
+ font-size: var(--chops-main-font-size);
+ box-sizing: border-box;
+ border: 1px solid var(--chops-chip-bg-color);
+ }
+ :host(:focus), :host(.selected) {
+ background: var(--chops-active-choice-bg);
+ border: 1px solid var(--chops-light-accent-color);
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ i.left {
+ margin: 0 4px 0 -6px;
+ }
+ button {
+ border-radius: 50%;
+ cursor: pointer;
+ background: none;
+ border: 0;
+ padding: 0;
+ margin: 0 -6px 0 4px;
+ display: inline-flex;
+ align-items: center;
+ transition: background-color 0.2s ease-in-out;
+ }
+ button[hidden] {
+ display: none;
+ }
+ button:hover {
+ background: var(--chops-gray-300);
+ }
+ i.material-icons {
+ color: var(--chops-primary-icon-color);
+ font-size: 14px;
+ user-select: none;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ ${this.thumbnail ? html`
+ <i class="material-icons left">${this.thumbnail}</i>
+ ` : ''}
+ <slot></slot>
+ ${this.buttonIcon ? html`
+ <button @click=${this.clickButton} aria-label=${this.buttonLabel}>
+ <i class="material-icons" aria-hidden="true"}>${this.buttonIcon}</i>
+ </button>
+ `: ''}
+ `;
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('focusable')) {
+ this.tabIndex = this.focusable ? '0' : undefined;
+ }
+ super.update(changedProperties);
+ }
+
+ /**
+ * @param {MouseEvent} e A click event.
+ * @fires CustomEvent#click-button
+ */
+ clickButton(e) {
+ this.dispatchEvent(new CustomEvent('click-button'));
+ }
+}
+customElements.define('chops-chip', ChopsChip);
diff --git a/static_src/elements/chops/chops-chip/chops-chip.test.js b/static_src/elements/chops/chops-chip/chops-chip.test.js
new file mode 100644
index 0000000..843000b
--- /dev/null
+++ b/static_src/elements/chops/chops-chip/chops-chip.test.js
@@ -0,0 +1,52 @@
+// 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 {ChopsChip} from './chops-chip.js';
+
+let element;
+
+describe('chops-chip', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-chip');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsChip);
+ });
+
+ it('icon is visible when defined', async () => {
+ await element.updateComplete;
+ assert.isNull(element.shadowRoot.querySelector('button'));
+
+ element.buttonIcon = 'close';
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.shadowRoot.querySelector('button'));
+ });
+
+ it('clicking icon fires event', async () => {
+ const onClickStub = sinon.stub();
+
+ element.buttonIcon = 'close';
+
+ await element.updateComplete;
+
+ element.addEventListener('click-button', onClickStub);
+
+ assert.isFalse(onClickStub.calledOnce);
+
+ const icon = element.shadowRoot.querySelector('button');
+ icon.click();
+
+ assert.isTrue(onClickStub.calledOnce);
+ });
+});
diff --git a/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js
new file mode 100644
index 0000000..e300588
--- /dev/null
+++ b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js
@@ -0,0 +1,133 @@
+// 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/chops/chops-button/chops-button.js';
+
+/**
+ * @typedef {Object} ChoiceOption
+ * @property {string=} value a unique string identifier for this option.
+ * @property {string=} text the text displayed to the user for this option.
+ * @property {string=} url the url this option navigates to.
+ */
+
+/**
+ * Shared component for rendering a set of choice chips.
+ * @extends {LitElement}
+ */
+export class ChopsChoiceButtons extends LitElement {
+ /** @override */
+ render() {
+ return html`
+ ${(this.options).map((option) => this._renderOption(option))}
+ `;
+ }
+
+ /**
+ * Rendering helper for rendering a single option.
+ * @param {ChoiceOption} option
+ * @return {TemplateResult}
+ */
+ _renderOption(option) {
+ const isSelected = this.value === option.value;
+ if (option.url) {
+ return html`
+ <a
+ ?selected=${isSelected}
+ aria-current=${isSelected ? 'true' : 'false'}
+ href=${option.url}
+ >${option.text}</a>
+ `;
+ }
+ return html`
+ <button
+ ?selected=${isSelected}
+ aria-current=${isSelected ? 'true' : 'false'}
+ @click=${this._setValue}
+ value=${option.value}
+ >${option.text}</button>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Array of options where each option is an Object with keys:
+ * {value, text, url}
+ */
+ options: {type: Array},
+ /**
+ * Which button is currently selected.
+ */
+ value: {type: String},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+
+ /**
+ * @type {Array<ChoiceOption>}
+ */
+ this.options = [];
+ this.value = '';
+ };
+
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: grid;
+ grid-auto-flow: column;
+ grid-template-columns: auto;
+ }
+ button, a {
+ display: block;
+ cursor: pointer;
+ border: 0;
+ color: var(--chops-gray-700);
+ font-weight: var(--chops-link-font-weight);
+ font-size: var(--chops-normal-font-size);
+ margin: 0.1em 4px;
+ padding: 4px 10px;
+ line-height: 1.4;
+ background: var(--chops-choice-bg);
+ text-decoration: none;
+ border-radius: 16px;
+ }
+ button[selected], a[selected] {
+ background: var(--chops-active-choice-bg);
+ color: var(--chops-link-color);
+ font-weight: var(--chops-link-font-weight);
+ border-radius: 16px;
+ }
+ `;
+ };
+
+ /**
+ * Public method for allowing parents to change the value of this component.
+ * @param {string} newValue
+ * @fires CustomEvent#change
+ */
+ setValue(newValue) {
+ if (newValue !== this.value) {
+ this.value = newValue;
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+ }
+
+ /**
+ * Private setter for updating the value of the component based on an internal
+ * click event.
+ * @param {MouseEvent} e
+ * @private
+ */
+ _setValue(e) {
+ this.setValue(e.target.getAttribute('value'));
+ }
+};
+
+customElements.define('chops-choice-buttons', ChopsChoiceButtons);
diff --git a/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js
new file mode 100644
index 0000000..e529735
--- /dev/null
+++ b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js
@@ -0,0 +1,99 @@
+// 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 {ChopsChoiceButtons} from './chops-choice-buttons';
+
+let element;
+
+describe('chops-choice-buttons', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-choice-buttons');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsChoiceButtons);
+ });
+
+ it('clicking option fires change event', async () => {
+ element.options = [{value: 'test', text: 'click me'}];
+ element.value = '';
+
+ await element.updateComplete;
+
+ const changeStub = sinon.stub();
+ element.addEventListener('change', changeStub);
+
+ const option = element.shadowRoot.querySelector('button');
+ option.click();
+
+ sinon.assert.calledOnce(changeStub);
+ });
+
+ it('clicking selected value does not fire change event', async () => {
+ element.options = [{value: 'test', text: 'click me'}];
+ element.value = 'test';
+
+ await element.updateComplete;
+
+ const changeStub = sinon.stub();
+ element.addEventListener('change', changeStub);
+
+ const option = element.shadowRoot.querySelector('button');
+ option.click();
+
+ sinon.assert.notCalled(changeStub);
+ });
+
+ it('selected value highlighted and has aria-current="true"', async () => {
+ element.options = [
+ {value: 'test', text: 'test'},
+ {value: 'selected', text: 'highlighted!'},
+ ];
+ element.value = 'selected';
+
+ await element.updateComplete;
+
+ const options = element.shadowRoot.querySelectorAll('button');
+
+ assert.isFalse(options[0].hasAttribute('selected'));
+ assert.isTrue(options[1].hasAttribute('selected'));
+
+ assert.equal(options[0].getAttribute('aria-current'), 'false');
+ assert.equal(options[1].getAttribute('aria-current'), 'true');
+ });
+
+ it('renders <a> tags when url set', async () => {
+ element.options = [
+ {value: 'test', text: 'test', url: 'http://google.com/'},
+ ];
+
+ await element.updateComplete;
+
+ const options = element.shadowRoot.querySelectorAll('a');
+
+ assert.equal(options[0].textContent.trim(), 'test');
+ assert.equal(options[0].href, 'http://google.com/');
+ });
+
+ it('selected value highlighted for <a> tags', async () => {
+ element.options = [
+ {value: 'test', text: 'test', url: 'http://google.com/'},
+ {value: 'selected', text: 'highlighted!', url: 'http://localhost/'},
+ ];
+ element.value = 'selected';
+
+ await element.updateComplete;
+
+ const options = element.shadowRoot.querySelectorAll('a');
+
+ assert.isFalse(options[0].hasAttribute('selected'));
+ assert.isTrue(options[1].hasAttribute('selected'));
+ });
+});
diff --git a/static_src/elements/chops/chops-collapse/chops-collapse.js b/static_src/elements/chops/chops-collapse/chops-collapse.js
new file mode 100644
index 0000000..0df3e21
--- /dev/null
+++ b/static_src/elements/chops/chops-collapse/chops-collapse.js
@@ -0,0 +1,62 @@
+// 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';
+
+/**
+ * `<chops-collapse>` displays a collapsible element.
+ *
+ */
+export class ChopsCollapse extends LitElement {
+ /** @override */
+ static get properties() {
+ return {
+ opened: {
+ type: Boolean,
+ reflect: true,
+ },
+ ariaHidden: {
+ attribute: 'aria-hidden',
+ type: Boolean,
+ reflect: true,
+ },
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return css`
+ :host, :host([hidden]) {
+ display: none;
+ }
+ :host([opened]) {
+ display: block;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <slot></slot>
+ `;
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.opened = false;
+ this.ariaHidden = true;
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('opened')) {
+ this.ariaHidden = !this.opened;
+ }
+ super.update(changedProperties);
+ }
+}
+customElements.define('chops-collapse', ChopsCollapse);
diff --git a/static_src/elements/chops/chops-collapse/chops-collapse.test.js b/static_src/elements/chops/chops-collapse/chops-collapse.test.js
new file mode 100644
index 0000000..7058b65
--- /dev/null
+++ b/static_src/elements/chops/chops-collapse/chops-collapse.test.js
@@ -0,0 +1,33 @@
+// 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 {ChopsCollapse} from './chops-collapse.js';
+
+
+let element;
+describe('chops-collapse', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-collapse');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsCollapse);
+ });
+
+ it('toggling chops-collapse changes aria-hidden', () => {
+ element.opened = true;
+
+ assert.isNull(element.getAttribute('aria-hidden'));
+
+ element.opened = false;
+
+ assert.isDefined(element.getAttribute('aria-hidden'));
+ });
+});
diff --git a/static_src/elements/chops/chops-dialog/chops-dialog.js b/static_src/elements/chops/chops-dialog/chops-dialog.js
new file mode 100644
index 0000000..0d40aa2
--- /dev/null
+++ b/static_src/elements/chops/chops-dialog/chops-dialog.js
@@ -0,0 +1,254 @@
+// 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';
+
+/**
+ * `<chops-dialog>` displays a modal/dialog overlay.
+ *
+ * @customElement
+ */
+export class ChopsDialog extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ position: fixed;
+ z-index: 9999;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ background-color: rgba(0,0,0,0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ :host(:not([opened])), [hidden] {
+ display: none;
+ visibility: hidden;
+ }
+ :host([closeOnOutsideClick]),
+ :host([closeOnOutsideClick]) .dialog::backdrop {
+ /* TODO(zhangtiff): Deprecate custom backdrop in favor of native
+ * browser backdrop.
+ */
+ cursor: pointer;
+ }
+ .dialog {
+ background: none;
+ border: 0;
+ max-width: 90%;
+ }
+ .dialog-content {
+ /* This extra div is here because otherwise the browser can't
+ * differentiate between a click event that hits the dialog element or
+ * its backdrop pseudoelement.
+ */
+ box-sizing: border-box;
+ background: var(--chops-white);
+ padding: 1em 16px;
+ cursor: default;
+ box-shadow: 0px 3px 20px 0px hsla(0, 0%, 0%, 0.4);
+ width: var(--chops-dialog-width);
+ max-width: var(--chops-dialog-max-width, 100%);
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <dialog class="dialog" role="dialog" @cancel=${this._cancelHandler}>
+ <div class="dialog-content">
+ <slot></slot>
+ </div>
+ </dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Whether the dialog should currently be displayed or not.
+ */
+ opened: {
+ type: Boolean,
+ reflect: true,
+ },
+ /**
+ * A boolean that determines whether clicking outside of the dialog
+ * window should close it.
+ */
+ closeOnOutsideClick: {
+ type: Boolean,
+ },
+ /**
+ * A function fired when the element tries to change its own opened
+ * state. This is useful if you want the dialog state managed outside
+ * of the dialog instead of with internal state. (ie: with Redux)
+ */
+ onOpenedChange: {
+ type: Object,
+ },
+ /**
+ * When True, disables exiting keys and closing on outside clicks.
+ * Forces the user to interact with the dialog rather than just dismissing
+ * it.
+ */
+ forced: {
+ type: Boolean,
+ },
+ _boundKeydownHandler: {
+ type: Object,
+ },
+ _previousFocusedElement: {
+ type: Object,
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.opened = false;
+ this.closeOnOutsideClick = false;
+ this.forced = false;
+ this._boundKeydownHandler = this._keydownHandler.bind(this);
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ this.addEventListener('click', (evt) => {
+ if (!this.opened || !this.closeOnOutsideClick || this.forced) return;
+
+ const hasDialog = evt.composedPath().find(
+ (node) => {
+ return node.classList && node.classList.contains('dialog-content');
+ }
+ );
+ if (hasDialog) return;
+
+ this.close();
+ });
+
+ window.addEventListener('keydown', this._boundKeydownHandler, true);
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener('keydown', this._boundKeydownHandler,
+ true);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('opened')) {
+ this._openedChanged(this.opened);
+ }
+ }
+
+ _keydownHandler(event) {
+ if (!this.opened) return;
+ if (event.key === 'Escape' && this.forced) {
+ // Stop users from using the Escape key in a forced dialog.
+ e.preventDefault();
+ }
+ }
+
+ /**
+ * Closes the dialog.
+ * May have its logic overridden by a custom onOpenChanged function.
+ */
+ close() {
+ if (this.onOpenedChange) {
+ this.onOpenedChange(false);
+ } else {
+ this.opened = false;
+ }
+ }
+
+ /**
+ * Opens the dialog.
+ * May have its logic overridden by a custom onOpenChanged function.
+ */
+ open() {
+ if (this.onOpenedChange) {
+ this.onOpenedChange(true);
+ } else {
+ this.opened = true;
+ }
+ }
+
+ /**
+ * Switches the dialog from open to closed or vice versa.
+ */
+ toggle() {
+ this.opened = !this.opened;
+ }
+
+ _cancelHandler(evt) {
+ if (!this.forced) {
+ this.close();
+ } else {
+ evt.preventDefault();
+ }
+ }
+
+ _getActiveElement() {
+ // document.activeElement alone isn't sufficient to find the active
+ // element within shadow dom.
+ let active = document.activeElement || document.body;
+ let activeRoot = active.shadowRoot || active.root;
+ while (activeRoot && activeRoot.activeElement) {
+ active = activeRoot.activeElement;
+ activeRoot = active.shadowRoot || active.root;
+ }
+ return active;
+ }
+
+ _openedChanged(opened) {
+ const dialog = this.shadowRoot.querySelector('dialog');
+ if (opened) {
+ // For accessibility, we want to ensure we remember the element that was
+ // focused before this dialog opened.
+ this._previousFocusedElement = this._getActiveElement();
+
+ if (dialog.showModal) {
+ dialog.showModal();
+ } else {
+ dialog.setAttribute('open', 'true');
+ }
+ if (this._previousFocusedElement) {
+ this._previousFocusedElement.blur();
+ }
+ } else {
+ if (dialog.close) {
+ dialog.close();
+ } else {
+ dialog.setAttribute('open', undefined);
+ }
+
+ if (this._previousFocusedElement) {
+ const element = this._previousFocusedElement;
+ requestAnimationFrame(() => {
+ // HACK. This is to prevent a possible accessibility bug where
+ // using a keypress to trigger a button that exits a modal causes
+ // the modal to immediately re-open because the button that
+ // originally opened the modal refocuses, and the keypress
+ // propagates.
+ element.focus();
+ });
+ }
+ }
+ }
+}
+
+customElements.define('chops-dialog', ChopsDialog);
diff --git a/static_src/elements/chops/chops-dialog/chops-dialog.test.js b/static_src/elements/chops/chops-dialog/chops-dialog.test.js
new file mode 100644
index 0000000..376496a
--- /dev/null
+++ b/static_src/elements/chops/chops-dialog/chops-dialog.test.js
@@ -0,0 +1,37 @@
+// 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 {expect, assert} from 'chai';
+import {ChopsDialog} from './chops-dialog.js';
+
+let element;
+
+describe('chops-dialog', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-dialog');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsDialog);
+ });
+
+ it('chops-dialog is visible when open', async () => {
+ element.opened = false;
+
+ await element.updateComplete;
+
+ expect(element).not.to.be.visible;
+
+ element.opened = true;
+
+ await element.updateComplete;
+
+ expect(element).to.be.visible;
+ });
+});
diff --git a/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js
new file mode 100644
index 0000000..3bcc0c6
--- /dev/null
+++ b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js
@@ -0,0 +1,70 @@
+// Copyright 2020 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/chops/chops-chip/chops-chip.js';
+
+/**
+ * `<chops-filter-chips>` displays a set of filter chips.
+ * https://material.io/components/chips/#filter-chips
+ */
+export class ChopsFilterChips extends LitElement {
+ /** @override */
+ static get properties() {
+ return {
+ options: {type: Array},
+ selected: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ /** @type {Array<string>} */
+ this.options = [];
+ /** @type {Object<string, boolean>} */
+ this.selected = {};
+ }
+
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: inline-flex;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`${this.options.map((option) => this._renderChip(option))}`;
+ }
+
+ /**
+ * Render a single chip.
+ * @param {string} option The text on the chip.
+ * @return {TemplateResult}
+ */
+ _renderChip(option) {
+ return html`
+ <chops-chip
+ @click=${this.select.bind(this, option)}
+ class=${this.selected[option] ? 'selected' : ''}
+ .thumbnail=${this.selected[option] ? 'check' : ''}>
+ ${option}
+ </chops-chip>
+ `;
+ }
+
+ /**
+ * Selects or unselects an option.
+ * @param {string} option The option to select or unselect.
+ * @fires Event#change
+ */
+ select(option) {
+ this.selected = {...this.selected, [option]: !this.selected[option]};
+ this.dispatchEvent(new Event('change'));
+ }
+}
+customElements.define('chops-filter-chips', ChopsFilterChips);
diff --git a/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js
new file mode 100644
index 0000000..3fd2671
--- /dev/null
+++ b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js
@@ -0,0 +1,58 @@
+// Copyright 2020 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 {ChopsFilterChips} from './chops-filter-chips.js';
+
+/** @type {ChopsFilterChips} */
+let element;
+
+describe('chops-filter-chips', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ element = document.createElement('chops-filter-chips');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsFilterChips);
+ });
+
+ it('renders', async () => {
+ element.options = ['one', 'two'];
+ element.selected = {two: true};
+ await element.updateComplete;
+
+ const firstChip = element.shadowRoot.firstElementChild;
+ assert.deepEqual(firstChip.className, '');
+ assert.deepEqual(firstChip.thumbnail, '');
+
+ const lastChip = element.shadowRoot.lastElementChild;
+ assert.deepEqual(lastChip.className, 'selected');
+ assert.deepEqual(lastChip.thumbnail, 'check');
+ });
+
+ it('click', async () => {
+ const onChangeStub = sinon.stub();
+
+ element.options = ['one'];
+ await element.updateComplete;
+
+ element.addEventListener('change', onChangeStub);
+ element.shadowRoot.firstElementChild.click();
+
+ assert.isTrue(element.selected.one);
+ sinon.assert.calledOnce(onChangeStub);
+
+ element.shadowRoot.firstElementChild.click();
+
+ assert.isFalse(element.selected.one);
+ sinon.assert.calledTwice(onChangeStub);
+ });
+});
diff --git a/static_src/elements/chops/chops-snackbar/chops-snackbar.js b/static_src/elements/chops/chops-snackbar/chops-snackbar.js
new file mode 100644
index 0000000..aea71b8
--- /dev/null
+++ b/static_src/elements/chops/chops-snackbar/chops-snackbar.js
@@ -0,0 +1,63 @@
+// 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';
+
+
+/**
+ * `<chops-snackbar>`
+ *
+ * A container for showing messages in a snackbar.
+ *
+ */
+export class ChopsSnackbar extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ align-items: center;
+ background-color: #333;
+ border-radius: 6px;
+ bottom: 1em;
+ left: 1em;
+ color: hsla(0, 0%, 100%, .87);
+ display: flex;
+ font-size: var(--chops-large-font-size);
+ padding: 16px;
+ position: fixed;
+ z-index: 1000;
+ }
+ button {
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ margin: 0;
+ margin-left: 8px;
+ padding: 0;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <slot></slot>
+ <button @click=${this.close}>
+ <i class="material-icons">close</i>
+ </button>
+ `;
+ }
+
+ /**
+ * Closes the snackbar.
+ * @fires CustomEvent#close
+ */
+ close() {
+ this.dispatchEvent(new CustomEvent('close'));
+ }
+}
+
+customElements.define('chops-snackbar', ChopsSnackbar);
diff --git a/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js b/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js
new file mode 100644
index 0000000..fa45d68
--- /dev/null
+++ b/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js
@@ -0,0 +1,36 @@
+// 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 {ChopsSnackbar} from './chops-snackbar.js';
+
+let element;
+
+describe('chops-snackbar', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-snackbar');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsSnackbar);
+ });
+
+ it('dispatches close event on close click', async () => {
+ element.opened = true;
+ await element.updateComplete;
+
+ const listener = sinon.stub();
+ element.addEventListener('close', listener);
+
+ element.shadowRoot.querySelector('button').click();
+
+ sinon.assert.calledOnce(listener);
+ });
+});
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
new file mode 100644
index 0000000..2fa1dc2
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
@@ -0,0 +1,109 @@
+// 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.
+
+const DEFAULT_DATE_LOCALE = 'en-US';
+
+// Creating the datetime formatter costs ~1.5 ms, so when formatting
+// multiple timestamps, it's more performant to reuse the formatter object.
+// Export FORMATTER and SHORT_FORMATTER for testing. The return value differs
+// based on time zone and browser, so we can't use static strings for testing.
+// We can't stub out the method because it's native code and can't be modified.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat/format#Avoid_comparing_formatted_date_values_to_static_values
+export const FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+ weekday: 'short',
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZoneName: 'short',
+});
+
+export const SHORT_FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+});
+
+export const MS_PER_MINUTE = 60 * 1000;
+export const MS_PER_HOUR = MS_PER_MINUTE * 60;
+export const MS_PER_DAY = MS_PER_HOUR * 24;
+export const MS_PER_MONTH = MS_PER_DAY * 30;
+
+/**
+ * Helper to determine if a Date was less than a month ago.
+ * @param {Date} date The date to check.
+ * @return {boolean} Whether the date was less than a
+ * month ago.
+ */
+function isLessThanAMonthAgo(date) {
+ const now = new Date();
+ const msDiff = Math.abs(Math.floor((now.getTime() - date.getTime())));
+ return msDiff < MS_PER_MONTH;
+}
+
+/**
+ * Displays timestamp in a standardized format to be re-used.
+ * @param {Date} date
+ * @return {string}
+ */
+export function standardTime(date) {
+ if (!date) return;
+ const absoluteTime = FORMATTER.format(date);
+
+ let timeAgoBit = '';
+ if (isLessThanAMonthAgo(date)) {
+ // Only show relative time if the time is less than a
+ // month ago because otherwise, it's not as useful.
+ timeAgoBit = ` (${relativeTime(date)})`;
+ }
+ return `${absoluteTime}${timeAgoBit}`;
+}
+
+/**
+ * Displays a timestamp in a format that's easy for a human to immediately
+ * reason about, based on long ago the time was.
+ * @param {Date} date native JavaScript Data Object.
+ * @return {string} Human-readable string of the date.
+ */
+export function relativeTime(date) {
+ if (!date) return;
+
+ const now = new Date();
+ let msDiff = now.getTime() - date.getTime();
+
+ // Use different wording depending on whether the time is in the
+ // future or past.
+ const pastOrPresentSuffix = msDiff < 0 ? 'from now' : 'ago';
+ msDiff = Math.abs(msDiff);
+
+ if (msDiff < MS_PER_MINUTE) {
+ // Less than a minute.
+ return 'just now';
+ } else if (msDiff < MS_PER_HOUR) {
+ // Less than an hour.
+ const minutes = Math.floor(msDiff / MS_PER_MINUTE);
+ if (minutes === 1) {
+ return `a minute ${pastOrPresentSuffix}`;
+ }
+ return `${minutes} minutes ${pastOrPresentSuffix}`;
+ } else if (msDiff < MS_PER_DAY) {
+ // Less than an day.
+ const hours = Math.floor(msDiff / MS_PER_HOUR);
+ if (hours === 1) {
+ return `an hour ${pastOrPresentSuffix}`;
+ }
+ return `${hours} hours ${pastOrPresentSuffix}`;
+ } else if (msDiff < MS_PER_MONTH) {
+ // Less than a month.
+ const days = Math.floor(msDiff / MS_PER_DAY);
+ if (days === 1) {
+ return `a day ${pastOrPresentSuffix}`;
+ }
+ return `${days} days ${pastOrPresentSuffix}`;
+ }
+
+ // A month or more ago. Better to show an exact date at this point.
+ return SHORT_FORMATTER.format(date);
+}
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
new file mode 100644
index 0000000..5fe344b
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
@@ -0,0 +1,112 @@
+// 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 {FORMATTER, MS_PER_MONTH, standardTime,
+ relativeTime} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let clock;
+
+describe('chops-timestamp-helpers', () => {
+ beforeEach(() => {
+ // Set clock to the Epoch.
+ clock = sinon.useFakeTimers({
+ now: new Date(0),
+ shouldAdvanceTime: false,
+ });
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ describe('standardTime', () => {
+ it('shows relative timestamp when less than a month ago', () => {
+ const date = new Date();
+ assert.equal(standardTime(date), `${FORMATTER.format(date)} (just now)`);
+ });
+
+ it('no relative time when more than a month in the future', () => {
+ const date = new Date(1548808276 * 1000);
+ assert.equal(standardTime(date), 'Tue, Jan 29, 2019, 4:31 PM PST');
+ });
+
+ it('no relative time when more than a month in the past', () => {
+ // Jan 29, 2019, 4:31 PM PST
+ const now = 1548808276 * 1000;
+ clock.tick(now);
+
+ const date = new Date(now - MS_PER_MONTH);
+ assert.equal(standardTime(date), 'Sun, Dec 30, 2018, 4:31 PM PST');
+ });
+ });
+
+ it('relativeTime future', () => {
+ assert.equal(relativeTime(new Date()), `just now`);
+
+ assert.equal(relativeTime(new Date(59 * 1000)), `just now`);
+
+ assert.equal(relativeTime(new Date(60 * 1000)), `a minute from now`);
+ assert.equal(relativeTime(new Date(2 * 60 * 1000)),
+ `2 minutes from now`);
+ assert.equal(relativeTime(new Date(59 * 60 * 1000)),
+ `59 minutes from now`);
+
+ assert.equal(relativeTime(new Date(60 * 60 * 1000)), `an hour from now`);
+ assert.equal(relativeTime(new Date(2 * 60 * 60 * 1000)),
+ `2 hours from now`);
+ assert.equal(relativeTime(new Date(23 * 60 * 60 * 1000)),
+ `23 hours from now`);
+
+ assert.equal(relativeTime(new Date(24 * 60 * 60 * 1000)),
+ `a day from now`);
+ assert.equal(relativeTime(new Date(2 * 24 * 60 * 60 * 1000)),
+ `2 days from now`);
+ assert.equal(relativeTime(new Date(29 * 24 * 60 * 60 * 1000)),
+ `29 days from now`);
+
+ assert.equal(relativeTime(new Date(30 * 24 * 60 * 60 * 1000)),
+ 'Jan 30, 1970');
+ });
+
+ it('relativeTime past', () => {
+ const baseTime = 234234 * 1000;
+
+ clock.tick(baseTime);
+
+ assert.equal(relativeTime(new Date()), `just now`);
+
+ assert.equal(relativeTime(new Date(baseTime - 59 * 1000)),
+ `just now`);
+
+ assert.equal(relativeTime(new Date(baseTime - 60 * 1000)),
+ `a minute ago`);
+ assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 1000)),
+ `2 minutes ago`);
+ assert.equal(relativeTime(new Date(baseTime - 59 * 60 * 1000)),
+ `59 minutes ago`);
+
+ assert.equal(relativeTime(new Date(baseTime - 60 * 60 * 1000)),
+ `an hour ago`);
+ assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 60 * 1000)),
+ `2 hours ago`);
+ assert.equal(relativeTime(new Date(baseTime - 23 * 60 * 60 * 1000)),
+ `23 hours ago`);
+
+ assert.equal(relativeTime(new Date(
+ baseTime - 24 * 60 * 60 * 1000)), `a day ago`);
+ assert.equal(relativeTime(new Date(
+ baseTime - 2 * 24 * 60 * 60 * 1000)), `2 days ago`);
+ assert.equal(relativeTime(new Date(
+ baseTime - 29 * 24 * 60 * 60 * 1000)), `29 days ago`);
+
+ assert.equal(relativeTime(new Date(
+ baseTime - 30 * 24 * 60 * 60 * 1000)), 'Dec 4, 1969');
+ });
+});
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
new file mode 100644
index 0000000..b7f157f
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
@@ -0,0 +1,93 @@
+// 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} from 'lit-element';
+
+import {standardTime, relativeTime} from './chops-timestamp-helpers.js';
+
+/**
+ * `<chops-timestamp>`
+ *
+ * This element shows a time in a human readable form.
+ *
+ * @customElement
+ */
+export class ChopsTimestamp extends LitElement {
+ /** @override */
+ render() {
+ return html`
+ ${this._displayedTime}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /** The data for the time which can be in any format readable by
+ * Date.parse.
+ */
+ timestamp: {type: String},
+ /** When true, a shorter version of the date will be displayed. */
+ short: {type: Boolean},
+ /**
+ * The Date object, which is stored in UTC, to be converted to a string.
+ */
+ _date: {type: Object},
+ };
+ }
+
+ /**
+ * @return {string} Human-readable timestamp.
+ */
+ get _displayedTime() {
+ const date = this._date;
+ const short = this.short;
+ // TODO(zhangtiff): Add logic to dynamically re-compute relative time
+ // based on set intervals.
+ if (!date) return;
+ if (short) {
+ return relativeTime(date);
+ }
+ return standardTime(date);
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('timestamp')) {
+ this._date = this._parseTimestamp(this.timestamp);
+ this.setAttribute('title', standardTime(this._date));
+ }
+ super.update(changedProperties);
+ }
+
+ /**
+ * Turns a timestamp string into a native JavaScript Date Object.
+ * @param {string} timestamp Timestamp string in either an ISO format or
+ * Unix timestamp format. If Unix time, the function expects the time in
+ * seconds, not milliseconds.
+ * @return {Date}
+ */
+ _parseTimestamp(timestamp) {
+ if (!timestamp) return;
+
+ let unixTimeMs = 0;
+ // Make sure to do Date.parse before Number.parseInt because parseInt
+ // will parse numbers within a string.
+ if (/^\d+$/.test(timestamp)) {
+ // Check if a string contains only digits before guessing it's
+ // unix time. This is necessary because Number.parseInt will parse
+ // number strings that contain non-numbers.
+ unixTimeMs = Number.parseInt(timestamp) * 1000;
+ } else {
+ // Date.parse will parse strings with only numbers as though those
+ // strings were truncated ISO formatted strings.
+ unixTimeMs = Date.parse(timestamp);
+ if (Number.isNaN(unixTimeMs)) {
+ throw new Error('Timestamp is in an invalid format.');
+ }
+ }
+ return new Date(unixTimeMs);
+ }
+}
+customElements.define('chops-timestamp', ChopsTimestamp);
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
new file mode 100644
index 0000000..21c227d
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
@@ -0,0 +1,88 @@
+// 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, expect} from 'chai';
+import {ChopsTimestamp} from './chops-timestamp.js';
+import {FORMATTER, SHORT_FORMATTER} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let element;
+let clock;
+
+describe('chops-timestamp', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-timestamp');
+ document.body.appendChild(element);
+
+ // Set clock to the Epoch.
+ clock = sinon.useFakeTimers({
+ now: new Date(0),
+ shouldAdvanceTime: false,
+ });
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ clock.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsTimestamp);
+ });
+
+ it('changing timestamp changes date', async () => {
+ const timestamp = 1548808276;
+ element.timestamp = String(timestamp);
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ FORMATTER.format(new Date(timestamp * 1000)));
+ });
+
+ it('parses ISO dates', async () => {
+ const timestamp = '2016-11-11';
+ element.timestamp = timestamp;
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ FORMATTER.format(new Date(timestamp)));
+ });
+
+ it('invalid timestamp format', () => {
+ expect(() => {
+ element._parseTimestamp('random string');
+ }).to.throw('Timestamp is in an invalid format.');
+ });
+
+ it('short time renders shorter time', async () => {
+ element.short = true;
+ element.timestamp = '5';
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ `just now`);
+
+ element.timestamp = '60';
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ `a minute from now`);
+
+ const timestamp = 1548808276;
+ element.timestamp = String(timestamp);
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ SHORT_FORMATTER.format(timestamp * 1000));
+ });
+});
diff --git a/static_src/elements/chops/chops-toggle/chops-toggle.js b/static_src/elements/chops/chops-toggle/chops-toggle.js
new file mode 100644
index 0000000..52868bd
--- /dev/null
+++ b/static_src/elements/chops/chops-toggle/chops-toggle.js
@@ -0,0 +1,124 @@
+// 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';
+
+/**
+ * `<chops-toggle>`
+ *
+ * A toggle button component. This component is primarily a wrapper
+ * around a native checkbox to allow easy sharing of styles.
+ *
+ */
+export class ChopsToggle extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ --chops-toggle-bg: none;
+ --chops-toggle-color: var(--chops-primary-font-color);
+ --chops-toggle-hover-bg: rgba(0, 0, 0, 0.3);
+ --chops-toggle-focus-border: hsl(193, 82%, 63%);
+ --chops-toggle-checked-bg: rgba(0, 0, 0, 0.6);
+ --chops-toggle-checked-color: var(--chops-white);
+ }
+ label {
+ background: var(--chops-toggle-bg);
+ color: var(--chops-toggle-color);
+ cursor: pointer;
+ align-items: center;
+ padding: 2px 4px;
+ border: var(--chops-normal-border);
+ border-radius: var(--chops-button-radius);
+ }
+ input[type="checkbox"] {
+ /* We need the checkbox to be hidden but still accessible. */
+ opacity: 0;
+ width: 0;
+ height: 0;
+ position: absolute;
+ top: -9999;
+ left: -9999;
+ }
+ input[type="checkbox"]:focus + label {
+ /* Make sure an outline shows around this element for
+ * accessibility.
+ */
+ box-shadow: 0 0 5px 1px var(--chops-toggle-focus-border);
+ }
+ input[type="checkbox"]:hover + label {
+ background: var(--chops-toggle-hover-bg);
+ }
+ input[type="checkbox"]:checked + label {
+ background: var(--chops-toggle-checked-bg);
+ color: var(--chops-toggle-checked-color);
+ }
+ input[type="checkbox"]:disabled + label {
+ opacity: 0.8;
+ cursor: default;
+ pointer-events: none;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <input id="checkbox"
+ type="checkbox"
+ ?checked=${this.checked}
+ ?disabled=${this.disabled}
+ @change=${this._checkedChangeHandler}
+ >
+ <label for="checkbox">
+ <slot></slot>
+ </label>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Note: At the moment, this component does not manage its own
+ * internal checked state. It expects its checked state to come
+ * from its parent, and its parent is expected to update the
+ * chops-checkbox's checked state on a change event.
+ *
+ * This can be generalized in the future to support multiple
+ * ways of managing checked state if needed.
+ **/
+ checked: {type: Boolean},
+ /**
+ * Whether the element currently allows checking or not.
+ */
+ disabled: {type: Boolean},
+ };
+ }
+
+ click() {
+ super.click();
+ this.shadowRoot.querySelector('#checkbox').click();
+ }
+
+ _checkedChangeHandler(evt) {
+ this._checkedChange(evt.target.checked);
+ }
+
+ /**
+ * @param {boolean} checked
+ * @fires CustomEvent#checked-change
+ * @private
+ */
+ _checkedChange(checked) {
+ if (checked === this.checked) return;
+ const customEvent = new CustomEvent('checked-change', {
+ detail: {
+ checked: checked,
+ },
+ });
+ this.dispatchEvent(customEvent);
+ }
+}
+customElements.define('chops-toggle', ChopsToggle);
diff --git a/static_src/elements/chops/chops-toggle/chops-toggle.test.js b/static_src/elements/chops/chops-toggle/chops-toggle.test.js
new file mode 100644
index 0000000..423c993
--- /dev/null
+++ b/static_src/elements/chops/chops-toggle/chops-toggle.test.js
@@ -0,0 +1,45 @@
+// 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 {ChopsToggle} from './chops-toggle.js';
+
+let element;
+
+describe('chops-toggle', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-toggle');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsToggle);
+ });
+
+ it('clicking toggle dispatches checked-change event', async () => {
+ element.checked = false;
+ sinon.stub(window, 'CustomEvent');
+ sinon.stub(element, 'dispatchEvent');
+
+ await element.updateComplete;
+
+ element.click();
+
+ assert.deepEqual(window.CustomEvent.args[0][0], 'checked-change');
+ assert.deepEqual(window.CustomEvent.args[0][1], {
+ detail: {checked: true},
+ });
+
+ assert.isTrue(window.CustomEvent.calledOnce);
+ assert.isTrue(element.dispatchEvent.calledOnce);
+
+ window.CustomEvent.restore();
+ element.dispatchEvent.restore();
+ });
+});
diff --git a/static_src/elements/ezt/ezt-app-base.js b/static_src/elements/ezt/ezt-app-base.js
new file mode 100644
index 0000000..0dc3eae
--- /dev/null
+++ b/static_src/elements/ezt/ezt-app-base.js
@@ -0,0 +1,62 @@
+// 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} from 'lit-element';
+import qs from 'qs';
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+/**
+ * `<ezt-app-base>`
+ *
+ * Base component meant to simulate a subset of the work mr-app does on
+ * EZT pages in order to allow us to more easily glue web components
+ * on EZT pages to SPA web components.
+ *
+ */
+export class EztAppBase extends connectStore(LitElement) {
+ /** @override */
+ static get properties() {
+ return {
+ projectName: {type: String},
+ userDisplayName: {type: String},
+ };
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ this.mapUrlToQueryParams();
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('userDisplayName') && this.userDisplayName) {
+ this.fetchUserData(this.userDisplayName);
+ }
+
+ if (changedProperties.has('projectName') && this.projectName) {
+ this.fetchProjectData(this.projectName);
+ }
+ }
+
+ fetchUserData(displayName) {
+ store.dispatch(userV0.fetch(displayName));
+ }
+
+ fetchProjectData(projectName) {
+ store.dispatch(projectV0.select(projectName));
+ store.dispatch(projectV0.fetch(projectName));
+ }
+
+ mapUrlToQueryParams() {
+ const params = qs.parse((window.location.search || '').substr(1));
+
+ store.dispatch(sitewide.setQueryParams(params));
+ }
+}
+customElements.define('ezt-app-base', EztAppBase);
diff --git a/static_src/elements/ezt/ezt-app-base.test.js b/static_src/elements/ezt/ezt-app-base.test.js
new file mode 100644
index 0000000..86eb5b1
--- /dev/null
+++ b/static_src/elements/ezt/ezt-app-base.test.js
@@ -0,0 +1,65 @@
+// 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 {EztAppBase} from './ezt-app-base.js';
+
+
+let element;
+
+describe('ezt-app-base', () => {
+ beforeEach(() => {
+ element = document.createElement('ezt-app-base');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, EztAppBase);
+ });
+
+ it('fetches user data when userDisplayName set', async () => {
+ sinon.stub(element, 'fetchUserData');
+
+ element.userDisplayName = 'test@example.com';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetchUserData);
+ sinon.assert.calledWith(element.fetchUserData, 'test@example.com');
+ });
+
+ it('does not fetch data when userDisplayName is empty', async () => {
+ sinon.stub(element, 'fetchUserData');
+ element.userDisplayName = '';
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchUserData);
+ });
+
+ it('fetches project data when projectName set', async () => {
+ sinon.stub(element, 'fetchProjectData');
+
+ element.projectName = 'chromium';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetchProjectData);
+ sinon.assert.calledWith(element.fetchProjectData, 'chromium');
+ });
+
+ it('does not fetch data when projectName is empty', async () => {
+ sinon.stub(element, 'fetchProjectData');
+ element.projectName = '';
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchProjectData);
+ });
+});
diff --git a/static_src/elements/ezt/ezt-element-package.js b/static_src/elements/ezt/ezt-element-package.js
new file mode 100644
index 0000000..90ffadb
--- /dev/null
+++ b/static_src/elements/ezt/ezt-element-package.js
@@ -0,0 +1,29 @@
+// 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.
+
+// This file bundles together all web components elements used on the
+// legacy EZT pages. This is to avoid having issues with registering
+// duplicate versions of dependencies.
+
+import page from 'page';
+
+import 'elements/framework/mr-dropdown/mr-account-dropdown.js';
+import 'elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+
+import 'elements/framework/mr-header/mr-header.js';
+import 'elements/issue-list/mr-chart/mr-chart.js';
+import 'elements/issue-detail/mr-flipper/mr-flipper.js';
+import 'elements/framework/mr-pref-toggle/mr-pref-toggle.js';
+import 'elements/ezt/ezt-show-columns-connector.js';
+import 'elements/ezt/ezt-app-base.js';
+
+// Register an empty set of page.js routes to allow the page() navigation
+// function to work.
+// Note: The EZT pages should NOT register the routes used by the SPA pages
+// without significant refactoring because doing so will lead to unexpected
+// routing behavior where the SPA is loaded on top of a server-rendered page
+// rather than instead of.
+page();
diff --git a/static_src/elements/ezt/ezt-footer-scripts-package.js b/static_src/elements/ezt/ezt-footer-scripts-package.js
new file mode 100644
index 0000000..85eeaa0
--- /dev/null
+++ b/static_src/elements/ezt/ezt-footer-scripts-package.js
@@ -0,0 +1,14 @@
+// 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.
+
+// This file bundles together scripts to be loaded through the legacy
+// EZT footer.
+
+import 'monitoring/client-logger.js';
+import 'monitoring/track-copy.js';
+
+// Allow EZT pages to import AutoRefreshPrpcClient.
+import AutoRefreshPrpcClient from 'prpc.js';
+
+window.AutoRefreshPrpcClient = AutoRefreshPrpcClient;
diff --git a/static_src/elements/ezt/ezt-show-columns-connector.js b/static_src/elements/ezt/ezt-show-columns-connector.js
new file mode 100644
index 0000000..c6b3347
--- /dev/null
+++ b/static_src/elements/ezt/ezt-show-columns-connector.js
@@ -0,0 +1,117 @@
+/**
+ * @fileoverview Description of this file.
+ */
+// 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} from 'lit-element';
+import {store, connectStore} from 'reducers/base.js';
+import qs from 'qs';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/mr-issue-list/mr-show-columns-dropdown.js';
+import {parseColSpec} from 'shared/issue-fields.js';
+import {equalsIgnoreCase} from 'shared/helpers.js';
+import {DEFAULT_ISSUE_FIELD_LIST} from 'shared/issue-fields.js';
+
+/**
+ * `<ezt-show-columns-connector>`
+ *
+ * Glue component to make "Show columns" dropdown work on EZT.
+ *
+ */
+export class EztShowColumnsConnector extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <mr-show-columns-dropdown
+ .defaultFields=${DEFAULT_ISSUE_FIELD_LIST}
+ .columns=${this.columns}
+ .phaseNames=${this.phaseNames}
+ .onHideColumn=${(name) => this.onHideColumn(name)}
+ .onShowColumn=${(name) => this.onShowColumn(name)}
+ ></mr-show-columns-dropdown>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ initialColumns: {type: Array},
+ hiddenColumns: {type: Object},
+ queryParams: {type: Object},
+ colspec: {type: String},
+ phasespec: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.hiddenColumns = new Set();
+ this.queryParams = {};
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.queryParams = sitewide.queryParams(state);
+ }
+
+ get columns() {
+ return this.initialColumns.filter((_, i) =>
+ !this.hiddenColumns.has(i));
+ }
+
+ get initialColumns() {
+ // EZT will always pass in a colspec.
+ return parseColSpec(this.colspec);
+ }
+
+ get phaseNames() {
+ return parseColSpec(this.phasespec);
+ }
+
+ onHideColumn(colName) {
+ // Custom column hiding logic to avoid reloading the
+ // EZT list page when a user hides a column.
+ const colIndex = this.initialColumns.findIndex(
+ (col) => equalsIgnoreCase(col, colName));
+
+ // Legacy code integration.
+ TKR_toggleColumn('hide_col_' + colIndex);
+
+ this.hiddenColumns.add(colIndex);
+
+ this.reflectColumnsToQueryParams();
+ this.requestUpdate();
+
+ // Don't continue navigation.
+ return false;
+ }
+
+ onShowColumn(colName) {
+ const colIndex = this.initialColumns.findIndex(
+ (col) => equalsIgnoreCase(col, colName));
+ if (colIndex >= 0) {
+ this.hiddenColumns.delete(colIndex);
+ TKR_toggleColumn('hide_col_' + colIndex);
+
+ this.reflectColumnsToQueryParams();
+ this.requestUpdate();
+ return false;
+ }
+ // Reload the page if this column is not part of the initial
+ // table render.
+ return true;
+ }
+
+ reflectColumnsToQueryParams() {
+ this.queryParams.colspec = this.columns.join(' ');
+
+ // Make sure the column changes in the URL.
+ window.history.replaceState({}, '', '?' + qs.stringify(this.queryParams));
+
+ store.dispatch(sitewide.setQueryParams(this.queryParams));
+ }
+}
+customElements.define('ezt-show-columns-connector', EztShowColumnsConnector);
diff --git a/static_src/elements/ezt/ezt-show-columns-connector.test.js b/static_src/elements/ezt/ezt-show-columns-connector.test.js
new file mode 100644
index 0000000..62bd13b
--- /dev/null
+++ b/static_src/elements/ezt/ezt-show-columns-connector.test.js
@@ -0,0 +1,41 @@
+// 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 {EztShowColumnsConnector} from './ezt-show-columns-connector.js';
+
+
+let element;
+
+describe('ezt-show-columns-connector', () => {
+ beforeEach(() => {
+ element = document.createElement('ezt-show-columns-connector');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, EztShowColumnsConnector);
+ });
+
+ it('initialColumns parses colspec', () => {
+ element.colspec = 'Summary ID Owner';
+ assert.deepEqual(element.initialColumns, ['Summary', 'ID', 'Owner']);
+ });
+
+ it('filters columns based on column mask', () => {
+ sinon.stub(element, 'initialColumns').get(() => ['ID', 'Summary']);
+ element.hiddenColumns = new Set([1]);
+
+ assert.deepEqual(element.columns, ['ID']);
+ });
+
+ it('phaseNames parses phasespec', () => {
+ element.phasespec = 'stable beta stable-exp';
+ assert.deepEqual(element.phaseNames, ['stable', 'beta', 'stable-exp']);
+ });
+});
diff --git a/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js
new file mode 100644
index 0000000..d9318fc
--- /dev/null
+++ b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js
@@ -0,0 +1,283 @@
+// 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} from 'lit-element';
+import 'elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js';
+import 'elements/framework/mr-error/mr-error.js';
+import 'react/mr-react-autocomplete.tsx';
+import {prpcClient} from 'prpc-client-instance.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {TEXT_TO_STATUS_ENUM} from 'shared/consts/approval.js';
+
+
+export const NO_UPDATES_MESSAGE =
+ 'User lacks approver perms for approval in all issues.';
+export const NO_APPROVALS_MESSAGE = 'These issues don\'t have any approvals.';
+
+export class MrBulkApprovalUpdate extends LitElement {
+ /** @override */
+ render() {
+ return html`
+ <style>
+ mr-bulk-approval-update {
+ display: block;
+ margin-top: 30px;
+ position: relative;
+ }
+ button.clickable-text {
+ background: none;
+ border: 0;
+ color: hsl(0, 0%, 39%);
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ .hidden {
+ display: none; !important;
+ }
+ .message {
+ background-color: beige;
+ width: 500px;
+ }
+ .note {
+ color: hsl(0, 0%, 25%);
+ font-size: 0.85em;
+ font-style: italic;
+ }
+ mr-bulk-approval-update table {
+ border: 1px dotted black;
+ cellspacing: 0;
+ cellpadding: 3;
+ }
+ #approversInput {
+ border-style: none;
+ }
+ </style>
+ <button
+ class="js-showApprovals clickable-text"
+ ?hidden=${this.approvalsFetched}
+ @click=${this.fetchApprovals}
+ >Show Approvals</button>
+ ${this.approvals.length ? html`
+ <form>
+ <table>
+ <tbody><tr>
+ <th><label for="approvalSelect">Approval:</label></th>
+ <td>
+ <select
+ id="approvalSelect"
+ @change=${this._changeHandlers.approval}
+ >
+ ${this.approvals.map(({fieldRef}) => html`
+ <option
+ value=${fieldRef.fieldName}
+ .selected=${fieldRef.fieldName === this._values.approval}
+ >
+ ${fieldRef.fieldName}
+ </option>
+ `)}
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <th><label for="approversInput">Approvers:</label></th>
+ <td>
+ <mr-react-autocomplete
+ label="approversInput"
+ vocabularyName="member"
+ .multiple=${true}
+ .value=${this._values.approvers}
+ .onChange=${this._changeHandlers.approvers}
+ ></mr-react-autocomplete>
+ </td>
+ </tr>
+ <tr><th><label for="statusInput">Status:</label></th>
+ <td>
+ <select
+ id="statusInput"
+ @change=${this._changeHandlers.status}
+ >
+ <option .selected=${!this._values.status}>
+ ${EMPTY_FIELD_VALUE}
+ </option>
+ ${this.statusOptions.map((status) => html`
+ <option
+ value=${status}
+ .selected=${status === this._values.status}
+ >${status}</option>
+ `)}
+ </select>
+ </td>
+ </tr>
+ <tr>
+ <th><label for="commentText">Comment:</label></th>
+ <td colspan="4">
+ <textarea
+ cols="30"
+ rows="3"
+ id="commentText"
+ placeholder="Add an approval comment"
+ .value=${this._values.comment || ''}
+ @change=${this._changeHandlers.comment}
+ ></textarea>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <button
+ class="js-save"
+ @click=${this.save}
+ >Update Approvals only</button>
+ </td>
+ <td>
+ <span class="note">
+ Note: Some approvals may not be updated if you lack
+ approver perms.
+ </span>
+ </td>
+ </tr>
+ </tbody></table>
+ </form>
+ `: ''}
+ <div class="message">
+ ${this.responseMessage}
+ ${this.errorMessage ? html`
+ <mr-error>${this.errorMessage}</mr-error>
+ ` : ''}
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ approvals: {type: Array},
+ approvalsFetched: {type: Boolean},
+ statusOptions: {type: Array},
+ localIdsStr: {type: String},
+ projectName: {type: String},
+ responseMessage: {type: String},
+ _values: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.approvals = [];
+ this.statusOptions = Object.keys(TEXT_TO_STATUS_ENUM);
+ this.responseMessage = '';
+
+ this._values = {};
+ this._changeHandlers = {
+ approval: this._onChange.bind(this, 'approval'),
+ approvers: this._onChange.bind(this, 'approvers'),
+ status: this._onChange.bind(this, 'status'),
+ comment: this._onChange.bind(this, 'comment'),
+ };
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ get issueRefs() {
+ const {projectName, localIdsStr} = this;
+ if (!projectName || !localIdsStr) return [];
+ const issueRefs = [];
+ const localIds = localIdsStr.split(',');
+ localIds.forEach((localId) => {
+ issueRefs.push({projectName: projectName, localId: localId});
+ });
+ return issueRefs;
+ }
+
+ fetchApprovals(evt) {
+ const message = {issueRefs: this.issueRefs};
+ prpcClient.call('monorail.Issues', 'ListApplicableFieldDefs', message).then(
+ (resp) => {
+ if (resp.fieldDefs) {
+ this.approvals = resp.fieldDefs.filter((fieldDef) => {
+ return fieldDef.fieldRef.type == 'APPROVAL_TYPE';
+ });
+ }
+ if (!this.approvals.length) {
+ this.errorMessage = NO_APPROVALS_MESSAGE;
+ }
+ this.approvalsFetched = true;
+ }, (error) => {
+ this.approvalsFetched = true;
+ this.errorMessage = error;
+ });
+ }
+
+ save(evt) {
+ this.responseMessage = '';
+ this.errorMessage = '';
+ this.toggleDisableForm();
+ const selectedFieldDef = this.approvals.find(
+ (approval) => approval.fieldRef.fieldName === this._values.approval
+ ) || this.approvals[0];
+ const message = {
+ issueRefs: this.issueRefs,
+ fieldRef: selectedFieldDef.fieldRef,
+ send_email: true,
+ };
+ message.commentContent = this._values.comment;
+ const delta = {};
+ if (this._values.status !== EMPTY_FIELD_VALUE) {
+ delta.status = TEXT_TO_STATUS_ENUM[this._values.status];
+ }
+ const approversAdded = this._values.approvers;
+ if (approversAdded) {
+ delta.approverRefsAdd = approversAdded.map(
+ (name) => ({'displayName': name}));
+ }
+ if (Object.keys(delta).length) {
+ message.approvalDelta = delta;
+ }
+ prpcClient.call('monorail.Issues', 'BulkUpdateApprovals', message).then(
+ (resp) => {
+ if (resp.issueRefs && resp.issueRefs.length) {
+ const idsStr = Array.from(resp.issueRefs,
+ (ref) => ref.localId).join(', ');
+ this.responseMessage = `${this.getTimeStamp()}: Updated ${
+ selectedFieldDef.fieldRef.fieldName} in issues: ${idsStr} (${
+ resp.issueRefs.length} of ${this.issueRefs.length}).`;
+ this._values = {};
+ } else {
+ this.errorMessage = NO_UPDATES_MESSAGE;
+ };
+ this.toggleDisableForm();
+ }, (error) => {
+ this.errorMessage = error;
+ this.toggleDisableForm();
+ });
+ }
+
+ getTimeStamp() {
+ const date = new Date();
+ return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
+ }
+
+ toggleDisableForm() {
+ this.querySelectorAll('input, textarea, select, button').forEach(
+ (input) => {
+ input.disabled = !input.disabled;
+ });
+ }
+
+ /**
+ * Generic onChange handler to be bound to each form field.
+ * @param {string} key Unique name for the form field we're binding this
+ * handler to. For example, 'owner', 'cc', or the name of a custom field.
+ * @param {Event | React.SyntheticEvent} event
+ * @param {string} value The new form value.
+ */
+ _onChange(key, event, value) {
+ this._values = {...this._values, [key]: value || event.target.value};
+ }
+}
+
+customElements.define('mr-bulk-approval-update', MrBulkApprovalUpdate);
diff --git a/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js
new file mode 100644
index 0000000..a0689e1
--- /dev/null
+++ b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js
@@ -0,0 +1,185 @@
+// 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 {fireEvent} from '@testing-library/react';
+
+import {MrBulkApprovalUpdate, NO_APPROVALS_MESSAGE,
+ NO_UPDATES_MESSAGE} from './mr-bulk-approval-update.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+
+describe('mr-bulk-approval-update', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-bulk-approval-update');
+ document.body.appendChild(element);
+
+ sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrBulkApprovalUpdate);
+ });
+
+ it('_computeIssueRefs: missing information', () => {
+ element.projectName = 'chromium';
+ assert.equal(element.issueRefs.length, 0);
+
+ element.projectName = null;
+ element.localIdsStr = '1,2,3,5';
+ assert.equal(element.issueRefs.length, 0);
+
+ element.localIdsStr = null;
+ assert.equal(element.issueRefs.length, 0);
+ });
+
+ it('_computeIssueRefs: normal', () => {
+ const project = 'chromium';
+ element.projectName = project;
+ element.localIdsStr = '1,2,3';
+ assert.deepEqual(element.issueRefs, [
+ {projectName: project, localId: '1'},
+ {projectName: project, localId: '2'},
+ {projectName: project, localId: '3'},
+ ]);
+ });
+
+ it('fetchApprovals: applicable fields exist', async () => {
+ const responseFieldDefs = [
+ {fieldRef: {type: 'INT_TYPE'}},
+ {fieldRef: {type: 'APPROVAL_TYPE'}},
+ {fieldRef: {type: 'APPROVAL_TYPE'}},
+ ];
+ const promise = Promise.resolve({fieldDefs: responseFieldDefs});
+ prpcClient.call.returns(promise);
+
+ sinon.spy(element, 'fetchApprovals');
+
+ await element.updateComplete;
+
+ element.querySelector('.js-showApprovals').click();
+ assert.isTrue(element.fetchApprovals.calledOnce);
+
+ // Wait for promise in fetchApprovals to resolve.
+ await promise;
+
+ assert.deepEqual([
+ {fieldRef: {type: 'APPROVAL_TYPE'}},
+ {fieldRef: {type: 'APPROVAL_TYPE'}},
+ ], element.approvals);
+ assert.equal(null, element.errorMessage);
+ });
+
+ it('fetchApprovals: applicable fields dont exist', async () => {
+ const promise = Promise.resolve({fieldDefs: []});
+ prpcClient.call.returns(promise);
+
+ await element.updateComplete;
+
+ element.querySelector('.js-showApprovals').click();
+
+ await promise;
+
+ assert.equal(element.approvals.length, 0);
+ assert.equal(NO_APPROVALS_MESSAGE, element.errorMessage);
+ });
+
+ it('save: normal', async () => {
+ const promise =
+ Promise.resolve({issueRefs: [{localId: '1'}, {localId: '3'}]});
+ prpcClient.call.returns(promise);
+ const fieldDefs = [
+ {fieldRef: {fieldName: 'Approval-One', type: 'APPROVAL_TYPE'}},
+ {fieldRef: {fieldName: 'Approval-Two', type: 'APPROVAL_TYPE'}},
+ ];
+ element.approvals = fieldDefs;
+ element.projectName = 'chromium';
+ element.localIdsStr = '1,2,3';
+
+ await element.updateComplete;
+
+ fireEvent.change(element.querySelector('#commentText'), {target: {value: 'comment'}});
+ fireEvent.change(element.querySelector('#statusInput'), {target: {value: 'NotApproved'}});
+ element.querySelector('.js-save').click();
+
+ // Wait for promise in save() to resolve.
+ await promise;
+ await element.updateComplete;
+
+ // Assert messages correct
+ assert.equal(
+ true,
+ element.responseMessage.includes(
+ 'Updated Approval-One in issues: 1, 3 (2 of 3).'));
+ assert.equal('', element.errorMessage);
+
+ // Assert all inputs not disabled.
+ element.querySelectorAll('input, textarea, select').forEach((input) => {
+ assert.equal(input.disabled, false);
+ });
+
+ // Assert all inputs cleared.
+ element.querySelectorAll('input, textarea').forEach((input) => {
+ assert.equal(input.value, '');
+ });
+ element.querySelectorAll('select').forEach((select) => {
+ assert.equal(select.selectedIndex, 0);
+ });
+
+ // Assert BulkUpdateApprovals correctly called.
+ const expectedMessage = {
+ approvalDelta: {status: 'NOT_APPROVED'},
+ commentContent: 'comment',
+ fieldRef: fieldDefs[0].fieldRef,
+ issueRefs: element.issueRefs,
+ send_email: true,
+ };
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Issues',
+ 'BulkUpdateApprovals',
+ expectedMessage);
+ });
+
+ it('save: no updates', async () => {
+ const promise = Promise.resolve({issueRefs: []});
+ prpcClient.call.returns(promise);
+ const fieldDefs = [
+ {fieldRef: {fieldName: 'Approval-One', type: 'APPROVAL_TYPE'}},
+ {fieldRef: {fieldName: 'Approval-Two', type: 'APPROVAL_TYPE'}},
+ ];
+ element.approvals = fieldDefs;
+ element.projectName = 'chromium';
+ element.localIdsStr = '1,2,3';
+
+ await element.updateComplete;
+
+ element.querySelector('#commentText').value = 'comment';
+ element.querySelector('#statusInput').value = 'NotApproved';
+ element.querySelector('.js-save').click();
+
+ // Wait for promise in save() to resolve
+ await promise;
+
+ // Assert messages correct.
+ assert.equal('', element.responseMessage);
+ assert.equal(NO_UPDATES_MESSAGE, element.errorMessage);
+
+ // Assert inputs not cleared.
+ assert.equal(element.querySelector('#commentText').value, 'comment');
+ assert.equal(element.querySelector('#statusInput').value, 'NotApproved');
+
+ // Assert inputs not disabled.
+ element.querySelectorAll('input, textarea, select').forEach((input) => {
+ assert.equal(input.disabled, false);
+ });
+ });
+});
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
new file mode 100644
index 0000000..a7870f6
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
@@ -0,0 +1,151 @@
+// 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 page from 'page';
+import qs from 'qs';
+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 {parseColSpec} from 'shared/issue-fields.js';
+
+/**
+ * `<mr-change-columns>`
+ *
+ * Dialog where the user can change columns on the list view.
+ *
+ */
+export class MrChangeColumns extends LitElement {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ .edit-actions {
+ margin: 0.5em 0;
+ text-align: right;
+ }
+ .input-grid {
+ align-items: center;
+ width: 800px;
+ max-width: 100%;
+ }
+ input {
+ box-sizing: border-box;
+ padding: 0.25em 4px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <chops-dialog closeOnOutsideClick>
+ <h3 class="medium-heading">Change list columns</h3>
+ <form id="changeColumns" @submit=${this._save}>
+ <div class="input-grid">
+ <label for="columnsInput">Columns: </label>
+ <input
+ id="columnsInput"
+ placeholder="Edit columns..."
+ value=${this.columns.join(' ')}
+ />
+ </div>
+ <div class="edit-actions">
+ <chops-button
+ @click=${this.close}
+ class="de-emphasized discard-button"
+ >
+ Discard
+ </chops-button>
+ <chops-button
+ @click=${this._save}
+ class="emphasized"
+ >
+ Update columns
+ </chops-button>
+ </div>
+ </form>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Array of the currently configured issue columns, used to set
+ * the default value.
+ */
+ columns: {type: Array},
+ /**
+ * Parsed query params for the current page, to be used in
+ * navigation.
+ */
+ queryParams: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.columns = [];
+ this.queryParams = {};
+
+ this._page = page;
+ }
+
+ /**
+ * Abstract out the computation of the current page. Useful for testing.
+ */
+ get _currentPage() {
+ return window.location.pathname;
+ }
+
+ /** Updates the URL query params with the new columns. */
+ save() {
+ const input = this.shadowRoot.querySelector('#columnsInput');
+ const newColumns = parseColSpec(input.value);
+
+ const params = {...this.queryParams};
+ params.colspec = newColumns.join('+');
+
+ // TODO(zhangtiff): Create a shared function to change only
+ // query params in a URL.
+ this._page(`${this._currentPage}?${qs.stringify(params)}`);
+
+ this.close();
+ }
+
+ /**
+ * Handles form submit events.
+ * @param {Event} e A click or submit event.
+ */
+ _save(e) {
+ e.preventDefault();
+ this.save();
+ }
+
+ /** Opens and resets this dialog. */
+ open() {
+ this.reset();
+ const dialog = this.shadowRoot.querySelector('chops-dialog');
+ dialog.open();
+ }
+
+ /** Closes this dialog. */
+ close() {
+ const dialog = this.shadowRoot.querySelector('chops-dialog');
+ dialog.close();
+ }
+
+ /** Resets the form in this dialog. */
+ reset() {
+ this.shadowRoot.querySelector('form').reset();
+ }
+}
+
+customElements.define('mr-change-columns', MrChangeColumns);
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
new file mode 100644
index 0000000..82e529d
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
@@ -0,0 +1,75 @@
+// 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 {MrChangeColumns} from './mr-change-columns.js';
+
+
+let element;
+
+describe('mr-change-columns', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-change-columns');
+ document.body.appendChild(element);
+
+ element._page = sinon.stub();
+ sinon.stub(element, '_currentPage').get(() => '/test');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrChangeColumns);
+ });
+
+ it('input initializes with currently set columns', async () => {
+ element.columns = ['ID', 'Summary'];
+
+ await element.updateComplete;
+
+ const input = element.shadowRoot.querySelector('#columnsInput');
+
+ assert.equal(input.value, 'ID Summary');
+ });
+
+ it('editing input and saving updates columns in URL', async () => {
+ element.columns = ['ID', 'Summary'];
+ element.queryParams = {};
+
+ await element.updateComplete;
+
+ const input = element.shadowRoot.querySelector('#columnsInput');
+ input.value = 'ID Summary Owner';
+
+ element.save();
+
+ sinon.assert.calledWith(element._page,
+ '/test?colspec=ID%2BSummary%2BOwner');
+ });
+
+ it('submitting form updates colspec', async () => {
+ element.columns = ['ID', 'Summary'];
+ element.queryParams = {};
+
+ await element.updateComplete;
+
+ const input = element.shadowRoot.querySelector('#columnsInput');
+ input.value = 'ID Summary Component';
+
+ // Note: HTMLFormElement.submit() does not fire event listeners.
+ const submitEvent = new Event('submit');
+ sinon.spy(submitEvent, 'preventDefault');
+ const form = element.shadowRoot.querySelector('form');
+ form.dispatchEvent(submitEvent);
+
+ // Preventing default is important to prevent native browser form submit
+ // from causing an additional navigation.
+ sinon.assert.calledOnce(submitEvent.preventDefault);
+
+ sinon.assert.calledWith(element._page,
+ '/test?colspec=ID%2BSummary%2BComponent');
+ });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
new file mode 100644
index 0000000..54565cf
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
@@ -0,0 +1,233 @@
+// Copyright 2020 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/chops/chops-dialog/chops-dialog.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {connectStore} from 'reducers/base.js';
+
+/**
+ * `<mr-issue-hotlists-dialog>`
+ *
+ * The base dialog that <mr-move-issue-hotlists-dialog> and
+ * <mr-update-issue-hotlists-dialog> inherits common methods and behaviors from.
+ * <mr-update-issue-hotlists-dialog> is used across multiple pages where as
+ * <mr-move-issue-hotlists-dialog> is largely used within Hotlists.
+ *
+ * Important: The `render` method should be overridden by child classes.
+ */
+export class MrIssueHotlistsDialog extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ font-size: var(--chops-main-font-size);
+ --chops-dialog-max-width: 500px;
+ }
+ .error {
+ max-width: 100%;
+ color: red;
+ margin-bottom: 1px;
+ }
+ select,
+ input {
+ box-sizing: border-box;
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ font-size: var(--chops-main-font-size);
+ }
+ input#filter {
+ margin-top: 4px;
+ width: 85%;
+ max-width: 240px;
+ }
+ .user-hotlists {
+ max-height: 240px;
+ overflow: auto;
+ }
+ .hotlist.filter-fail {
+ display: none;
+ }
+ i.material-icons {
+ font-size: 20px;
+ margin-right: 4px;
+ vertical-align: bottom;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <chops-dialog closeOnOutsideClick>
+ ${this.renderHeader()}
+ ${this.renderContent()}
+ </chops-dialog>
+ `;
+ }
+
+ /**
+ * Renders the dialog header.
+ * @return {TemplateResult}
+ */
+ renderHeader() {
+ return html`
+ <h3 class="medium-heading">Dialog elements below:</h3>
+ `;
+ }
+
+ /**
+ * Renders the dialog content.
+ * @return {TemplateResult}
+ */
+ renderContent() {
+ return html`
+ ${this.renderFilter()}
+ ${this.renderHotlists()}
+ ${this.renderError()}
+ `;
+ }
+
+ /**
+ * Renders the Hotlist filter.
+ * @return {TemplateResult}
+ */
+ renderFilter() {
+ return html`
+ <input id="filter" type="text" @keyup=${this.filterHotlists}>
+ <i class="material-icons">search</i>
+ `;
+ }
+
+ /**
+ * Renders the user's Hotlists.
+ * @return {TemplateResult}
+ */
+ renderHotlists() {
+ return html`
+ <div class="user-hotlists">
+ ${this.filteredHotlists.length ?
+ this.filteredHotlists.map(this.renderFilteredHotlist, this) : ''}
+ </div>
+ `;
+ }
+
+ /**
+ * Renders a user's filtered Hotlist.
+ * @param {HotlistV0} hotlist The user Hotlist to render.
+ * @return {TemplateResult}
+ */
+ renderFilteredHotlist(hotlist) {
+ return html`
+ <div
+ class="hotlist"
+ data-hotlist-name="${hotlist.name}"
+ >
+ ${hotlist.name}
+ </div>`;
+ }
+
+ /**
+ * Renders dialog error.
+ * @return {TemplateResult}
+ */
+ renderError() {
+ return html`
+ <br>
+ ${this.error ? html`
+ <div class="error">${this.error}</div>
+ `: ''}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ // Populated from Redux.
+ userHotlists: {type: Array},
+ filteredHotlists: {type: Array},
+ issueRefs: {type: Array},
+ error: {type: String},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.userHotlists = userV0.currentUser(state).hotlists;
+ // TODO(https://crbug.com/monorail/7778): Switch to users.js and use V3 API
+ // to make a call to GatherHotlistsForUser.
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** @type {Array} */
+ this.userHotlists = [];
+
+ /** @type {Array} */
+ this.filteredHotlists = this.userHotlists;
+
+ /** @type {Array<IssueRef>} */
+ this.issueRefs = [];
+
+ /** @type {string} */
+ this.error = '';
+ }
+
+ /**
+ * Opens the dialog.
+ */
+ open() {
+ this.reset();
+ this.shadowRoot.querySelector('chops-dialog').open();
+ }
+
+ /**
+ * Resets any changes to the form and error.
+ */
+ reset() {
+ this.error = '';
+ const filter = this.shadowRoot.querySelector('#filter');
+ filter.value = '';
+ this.filterHotlists();
+ }
+
+ /**
+ * Closes the dialog.
+ */
+ close() {
+ this.shadowRoot.querySelector('chops-dialog').close();
+ }
+
+ /**
+ * Filters the visible Hotlists with the given user input.
+ * Requires filter to be an input element with its id as "filter".
+ */
+ filterHotlists() {
+ const input = this.shadowRoot.querySelector('#filter');
+ if (!input) {
+ // Short circuit because there's no filter.
+ this.filteredHotlists = this.userHotlists;
+ } else {
+ const filter = input.value.toLowerCase();
+ const visibleHotlists = [];
+ this.userHotlists.forEach((hotlist) => {
+ const hotlistName = hotlist.name.toLowerCase();
+ if (hotlistName.includes(filter)) {
+ visibleHotlists.push(hotlist);
+ }
+ });
+ this.filteredHotlists = visibleHotlists;
+ }
+ }
+}
+
+customElements.define('mr-issue-hotlists-dialog', MrIssueHotlistsDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..911c1a0
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
@@ -0,0 +1,78 @@
+// Copyright 2020 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 {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog.js';
+
+let element;
+const EXAMPLE_USER_HOTLISTS = [
+ {name: 'Hotlist-1'},
+ {name: 'Hotlist-2'},
+ {name: 'ac-apple-1'},
+ {name: 'ac-frita-1'},
+];
+
+describe('mr-issue-hotlists-dialog', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-issue-hotlists-dialog');
+ document.body.appendChild(element);
+
+ await element.updateComplete;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueHotlistsDialog);
+ assert.include(element.shadowRoot.innerHTML, 'Dialog elements below');
+ });
+
+ it('filters hotlists', async () => {
+ element.userHotlists = EXAMPLE_USER_HOTLISTS;
+ element.open();
+ await element.updateComplete;
+
+ const initialHotlists = element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(initialHotlists.length, 4);
+ const filterInput = element.shadowRoot.querySelector('#filter');
+ filterInput.value = 'list';
+ element.filterHotlists();
+ await element.updateComplete;
+ let visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 2);
+
+ filterInput.value = '2';
+ element.filterHotlists();
+ await element.updateComplete;
+ visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 1);
+ });
+
+ it('resets filter on open', async () => {
+ element.userHotlists = EXAMPLE_USER_HOTLISTS;
+ element.open();
+ await element.updateComplete;
+
+ const filterInput = element.shadowRoot.querySelector('#filter');
+ filterInput.value = 'ac';
+ element.filterHotlists();
+ await element.updateComplete;
+ let visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 2);
+
+ element.close();
+ element.open();
+ await element.updateComplete;
+
+ assert.equal(filterInput.value, '');
+ visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 4);
+ });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
new file mode 100644
index 0000000..e7c1cd3
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
@@ -0,0 +1,141 @@
+// Copyright 2020 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 'elements/framework/mr-warning/mr-warning.js';
+import {hotlists} from 'reducers/hotlists.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-move-issue-hotlists-dialog>`
+ *
+ * Displays a dialog to select the Hotlist to move the provided Issues.
+ */
+export class MrMoveIssueDialog extends MrIssueHotlistsDialog {
+ /** @override */
+ static get styles() {
+ return [
+ super.styles,
+ css`
+ .hotlist {
+ padding: 4px;
+ }
+ .hotlist:hover {
+ background: var(--chops-active-choice-bg);
+ cursor: pointer;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ renderHeader() {
+ const warningText =
+ `Moving issues will remove them from ${this._viewedHotlist ?
+ this._viewedHotlist.displayName : 'this hotlist'}.`;
+ return html`
+ <h3 class="medium-heading">Move issues to hotlist</h3>
+ <mr-warning title=${warningText}>${warningText}</mr-warning>
+ `;
+ }
+
+ /** @override */
+ renderFilteredHotlist(hotlist) {
+ if (this._viewedHotlist &&
+ hotlist.name === this._viewedHotlist.displayName) return;
+ return html`
+ <div
+ class="hotlist"
+ data-hotlist-name="${hotlist.name}"
+ @click=${this._targetHotlistPicked}>
+ ${hotlist.name}
+ </div>`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ ...MrIssueHotlistsDialog.properties,
+ // Populated from Redux.
+ _viewedHotlist: {type: Object},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ super.stateChanged(state);
+ this._viewedHotlist = hotlists.viewedHotlist(state);
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /**
+ * The currently viewed Hotlist.
+ * @type {?Hotlist}
+ **/
+ this._viewedHotlist = null;
+ }
+
+ /**
+ * Handles picking a Hotlist to move to.
+ * @param {Event} e
+ */
+ async _targetHotlistPicked(e) {
+ const targetHotlistName = e.target.dataset.hotlistName;
+ const changes = {
+ added: [],
+ removed: [],
+ };
+
+ for (const hotlist of this.userHotlists) {
+ // We move from the current Hotlist to the target Hotlist.
+ if (changes.added.length === 1 && changes.removed.length === 1) break;
+ const change = {
+ name: hotlist.name,
+ owner: hotlist.ownerRef,
+ };
+ if (hotlist.name === targetHotlistName) {
+ changes.added.push(change);
+ } else if (hotlist.name === this._viewedHotlist.displayName) {
+ changes.removed.push(change);
+ }
+ }
+
+ const issueRefs = this.issueRefs;
+ if (!issueRefs) return;
+
+ // TODO(https://crbug.com/monorail/7778): Use action creators.
+ const promises = [];
+ if (changes.added && changes.added.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'AddIssuesToHotlists', {
+ hotlistRefs: changes.added,
+ issueRefs,
+ },
+ ));
+ }
+ if (changes.removed && changes.removed.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'RemoveIssuesFromHotlists', {
+ hotlistRefs: changes.removed,
+ issueRefs,
+ },
+ ));
+ }
+
+ try {
+ await Promise.all(promises);
+ this.dispatchEvent(new Event('saveSuccess'));
+ this.close();
+ } catch (error) {
+ this.error = error.message || error.description;
+ }
+ }
+}
+
+customElements.define('mr-move-issue-hotlists-dialog', MrMoveIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..7a2dd5c
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
@@ -0,0 +1,105 @@
+// 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 {MrMoveIssueDialog} from './mr-move-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import * as example from 'shared/test/constants-hotlists.js';
+
+let element;
+let waitForPromises;
+
+describe('mr-move-issue-hotlists-dialog', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-move-issue-hotlists-dialog');
+ document.body.appendChild(element);
+
+ // We need to wait for promisees to resolve. Alone, the updateComplete
+ // returns without allowing our Promise.all to resolve.
+ waitForPromises = async () => element.updateComplete;
+
+ element.userHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-3', ownerRef: {userId: 67890}},
+ {name: example.HOTLIST.displayName, ownerRef: {userId: 67890}},
+ ];
+ element.user = {userId: 67890};
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+ element._viewedHotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrMoveIssueDialog);
+ });
+
+ it('clicking a hotlist moves the issue', async () => {
+ element.open();
+ await element.updateComplete;
+
+ const targetHotlist =element.shadowRoot.querySelector(
+ '.hotlist[data-hotlist-name="Hotlist-2"]');
+ assert.isNotNull(targetHotlist);
+ targetHotlist.click();
+ await element.updateComplete;
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'AddIssuesToHotlists', {
+ hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'RemoveIssuesFromHotlists', {
+ hotlistRefs: [{
+ name: example.HOTLIST.displayName,
+ owner: {userId: 67890},
+ }],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('dispatches event upon successfully moving', async () => {
+ element.open();
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+ sinon.stub(element, 'close');
+ await element.updateComplete;
+
+ const targetHotlist =element.shadowRoot.querySelector(
+ '.hotlist[data-hotlist-name="Hotlist-2"]');
+ targetHotlist.click();
+
+ await waitForPromises();
+ sinon.assert.calledOnce(savedStub);
+ sinon.assert.calledOnce(element.close);
+ });
+
+ it('dispatches no event upon error saving', async () => {
+ const mistakes = 'Mistakes were made';
+ const error = new Error(mistakes);
+ prpcClient.call.returns(Promise.reject(error));
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+ element.open();
+ await element.updateComplete;
+
+ const targetHotlist =element.shadowRoot.querySelector(
+ '.hotlist[data-hotlist-name="Hotlist-2"]');
+ targetHotlist.click();
+
+ await waitForPromises();
+ sinon.assert.notCalled(savedStub);
+ assert.include(element.shadowRoot.innerHTML, mistakes);
+ });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
new file mode 100644
index 0000000..08a8b25
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
@@ -0,0 +1,340 @@
+// 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 deepEqual from 'deep-equal';
+
+import 'elements/chops/chops-checkbox/chops-checkbox.js';
+import {store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-update-issue-hotlists-dialog>`
+ *
+ * Displays a dialog with the current hotlists's issues allowing the user to
+ * update which hotlists the issues are a member of.
+ */
+export class MrUpdateIssueDialog extends MrIssueHotlistsDialog {
+ /** @override */
+ static get styles() {
+ return [
+ ...super.styles,
+ css`
+ input[type="checkbox"] {
+ width: auto;
+ height: auto;
+ }
+ button.toggle {
+ background: none;
+ color: hsl(240, 100%, 40%);
+ border: 0;
+ width: 100%;
+ padding: 0.25em 0;
+ text-align: left;
+ }
+ button.toggle:hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ label, chops-checkbox {
+ display: flex;
+ line-height: 200%;
+ align-items: center;
+ width: 100%;
+ text-align: left;
+ font-weight: normal;
+ padding: 0.25em 8px;
+ box-sizing: border-box;
+ }
+ label input[type="checkbox"] {
+ margin-right: 8px;
+ }
+ .discard-button {
+ margin-right: 16px;
+ }
+ .edit-actions {
+ width: 100%;
+ margin: 0.5em 0;
+ text-align: right;
+ }
+ .input-grid {
+ align-items: center;
+ }
+ .input-grid > input {
+ width: 200px;
+ max-width: 100%;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ renderHeader() {
+ return html`
+ <h3 class="medium-heading">Add issue to hotlists</h3>
+ `;
+ }
+
+ /** @override */
+ renderContent() {
+ return html`
+ ${this.renderFilter()}
+ <form id="issueHotlistsForm">
+ ${this.renderHotlists()}
+ <h3 class="medium-heading">Create new hotlist</h3>
+ <div class="input-grid">
+ <label for="newHotlistName">New hotlist name:</label>
+ <input type="text" name="newHotlistName">
+ </div>
+ ${this.renderError()}
+ <div class="edit-actions">
+ <chops-button
+ class="de-emphasized discard-button"
+ ?disabled=${this.disabled}
+ @click=${this.discard}
+ >
+ Discard
+ </chops-button>
+ <chops-button
+ class="emphasized"
+ ?disabled=${this.disabled}
+ @click=${this.save}
+ >
+ Save changes
+ </chops-button>
+ </div>
+ </form>
+ `;
+ }
+
+ /** @override */
+ renderFilteredHotlist(hotlist) {
+ return html`
+ <chops-checkbox
+ class="hotlist"
+ title=${this._checkboxTitle(hotlist, this.issueHotlists)}
+ data-hotlist-name="${hotlist.name}"
+ ?checked=${this.hotlistsToAdd.has(hotlist.name)}
+ @checked-change=${this._targetHotlistChecked}
+ >
+ ${hotlist.name}
+ </chops-checkbox>`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ ...super.properties,
+ viewedIssueRef: {type: Object},
+ issueHotlists: {type: Array},
+ user: {type: Object},
+ hotlistsToAdd: {
+ type: Object,
+ hasChanged(newVal, oldVal) {
+ return !deepEqual(newVal, oldVal);
+ },
+ },
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ super.stateChanged(state);
+ this.viewedIssueRef = issueV0.viewedIssueRef(state);
+ this.user = userV0.currentUser(state);
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** The list of Hotlists attached to the issueRefs. */
+ this.issueHotlists = [];
+
+ /** The Set of Hotlist names that the Issues will be added to. */
+ this.hotlistsToAdd = this._initializeHotlistsToAdd();
+ }
+
+ /** @override */
+ reset() {
+ const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+ form.reset();
+ // LitElement's hasChanged needs an assignment to verify Set objects.
+ // https://lit-element.polymer-project.org/guide/properties#haschanged
+ this.hotlistsToAdd = this._initializeHotlistsToAdd();
+ super.reset();
+ }
+
+ /**
+ * An alias to the close method.
+ */
+ discard() {
+ this.close();
+ }
+
+ /**
+ * Saves all changes that were found in the dialog and issues async requests
+ * to update the issues.
+ * @fires Event#saveSuccess
+ */
+ async save() {
+ const changes = this.changes;
+ const issueRefs = this.issueRefs;
+ const viewedRef = this.viewedIssueRef;
+
+ if (!issueRefs || !changes) return;
+
+ // TODO(https://crbug.com/monorail/7778): Use action creators.
+ const promises = [];
+ if (changes.added && changes.added.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'AddIssuesToHotlists', {
+ hotlistRefs: changes.added,
+ issueRefs,
+ },
+ ));
+ }
+ if (changes.removed && changes.removed.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'RemoveIssuesFromHotlists', {
+ hotlistRefs: changes.removed,
+ issueRefs,
+ },
+ ));
+ }
+ if (changes.created) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'CreateHotlist', {
+ name: changes.created.name,
+ summary: changes.created.summary,
+ issueRefs,
+ },
+ ));
+ }
+
+ try {
+ await Promise.all(promises);
+
+ // Refresh the viewed issue's hotlists only if there is a viewed issue.
+ if (viewedRef) {
+ const viewedIssueWasUpdated = issueRefs.find((ref) =>
+ ref.projectName === viewedRef.projectName &&
+ ref.localId === viewedRef.localId);
+ if (viewedIssueWasUpdated) {
+ store.dispatch(issueV0.fetchHotlists(viewedRef));
+ }
+ }
+ store.dispatch(userV0.fetchHotlists({userId: this.user.userId}));
+ this.dispatchEvent(new Event('saveSuccess'));
+ this.close();
+ } catch (error) {
+ this.error = error.description;
+ }
+ }
+
+ /**
+ * Returns whether a given hotlist matches any of the given issue's hotlists.
+ * @param {Hotlist} hotlist Hotlist to look for.
+ * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+ * @return {boolean}
+ */
+ _issueInHotlist(hotlist, issueHotlists) {
+ return issueHotlists.some((issueHotlist) => {
+ // TODO(https://crbug.com/monorail/7451): use `===`.
+ return (hotlist.ownerRef.userId == issueHotlist.ownerRef.userId &&
+ hotlist.name === issueHotlist.name);
+ });
+ }
+
+ /**
+ * Get a Set of Hotlists to add the Issues to based on the
+ * Get the initial Set of Hotlists that Issues will be added to. Calculated
+ * using userHotlists and issueHotlists.
+ * @return {!Set<string>}
+ */
+ _initializeHotlistsToAdd() {
+ const userHotlistsInIssueHotlists = this.userHotlists.reduce(
+ (acc, hotlist) => {
+ if (this._issueInHotlist(hotlist, this.issueHotlists)) {
+ acc.push(hotlist.name);
+ }
+ return acc;
+ }, []);
+ return new Set(userHotlistsInIssueHotlists);
+ }
+
+ /**
+ * Gets the checkbox title, depending on the checked state.
+ * @param {boolean} isChecked Whether the input is checked.
+ * @return {string}
+ */
+ _getCheckboxTitle(isChecked) {
+ return (isChecked ? 'Remove issue from' : 'Add issue to') + ' this hotlist';
+ }
+
+ /**
+ * The checkbox title for the issue, shown on hover and for a11y.
+ * @param {Hotlist} hotlist Hotlist to look for.
+ * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+ * @return {string}
+ */
+ _checkboxTitle(hotlist, issueHotlists) {
+ return this._getCheckboxTitle(this._issueInHotlist(hotlist, issueHotlists));
+ }
+
+ /**
+ * Handles when the target Hotlist chops-checkbox has been checked.
+ * @param {Event} e
+ */
+ _targetHotlistChecked(e) {
+ const hotlistName = e.target.dataset.hotlistName;
+ const currentHotlistsToAdd = new Set(this.hotlistsToAdd);
+ if (hotlistName && e.detail.checked) {
+ currentHotlistsToAdd.add(hotlistName);
+ } else {
+ currentHotlistsToAdd.delete(hotlistName);
+ }
+ // LitElement's hasChanged needs an assignment to verify Set objects.
+ // https://lit-element.polymer-project.org/guide/properties#haschanged
+ this.hotlistsToAdd = currentHotlistsToAdd;
+ e.target.title = this._getCheckboxTitle(e.target.checked);
+ }
+
+ /**
+ * Gets the changes between the added, removed, and created hotlists .
+ */
+ get changes() {
+ const changes = {
+ added: [],
+ removed: [],
+ };
+ const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+ this.userHotlists.forEach((hotlist) => {
+ const issueInHotlist = this._issueInHotlist(hotlist, this.issueHotlists);
+ if (issueInHotlist && !this.hotlistsToAdd.has(hotlist.name)) {
+ changes.removed.push({
+ name: hotlist.name,
+ owner: hotlist.ownerRef,
+ });
+ } else if (!issueInHotlist && this.hotlistsToAdd.has(hotlist.name)) {
+ changes.added.push({
+ name: hotlist.name,
+ owner: hotlist.ownerRef,
+ });
+ }
+ });
+ if (form.newHotlistName.value) {
+ changes.created = {
+ name: form.newHotlistName.value,
+ summary: 'Hotlist created from issue.',
+ };
+ }
+ return changes;
+ }
+}
+
+customElements.define('mr-update-issue-hotlists-dialog', MrUpdateIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..954b8b9
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
@@ -0,0 +1,193 @@
+// 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 {MrUpdateIssueDialog} from './mr-update-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let form;
+
+describe('mr-update-issue-hotlists-dialog', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-update-issue-hotlists-dialog');
+ document.body.appendChild(element);
+
+ await element.updateComplete;
+ form = element.shadowRoot.querySelector('#issueHotlistsForm');
+
+ sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrUpdateIssueDialog);
+ });
+
+ it('no changes', () => {
+ assert.deepEqual(element.changes, {added: [], removed: []});
+ });
+
+ it('clicking on issues produces changes', async () => {
+ element.issueHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ ];
+ element.userHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+ ];
+ element.user = {userId: 67890};
+
+ element.open();
+ await element.updateComplete;
+
+ const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+ chopsCheckboxes[0].click();
+ chopsCheckboxes[1].click();
+ assert.deepEqual(element.changes, {
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ removed: [{name: 'Hotlist-1', owner: {userId: 67890}}],
+ });
+ });
+
+ it('adding new hotlist produces changes', async () => {
+ await element.updateComplete;
+ form.newHotlistName.value = 'New-Hotlist';
+ assert.deepEqual(element.changes, {
+ added: [],
+ removed: [],
+ created: {
+ name: 'New-Hotlist',
+ summary: 'Hotlist created from issue.',
+ },
+ });
+ });
+
+ it('reset changes', async () => {
+ element.issueHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ ];
+ element.userHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+ ];
+ element.user = {userId: 67890};
+
+ element.open();
+ await element.updateComplete;
+
+ const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+ const checkbox1 = chopsCheckboxes[0];
+ const checkbox2 = chopsCheckboxes[1];
+ checkbox1.click();
+ checkbox2.click();
+ form.newHotlisName = 'New-Hotlist';
+ await element.reset();
+ assert.isTrue(checkbox1.checked);
+ assert.isNotTrue(checkbox2.checked); // Falsey property.
+ assert.equal(form.newHotlistName.value, '');
+ });
+
+ it('saving adds issues to hotlist', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'AddIssuesToHotlists', {
+ hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('saving removes issues from hotlist', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ removed: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'RemoveIssuesFromHotlists', {
+ hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('saving creates new hotlist with issues', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ created: {name: 'MyHotlist', summary: 'the best hotlist'},
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'CreateHotlist', {
+ name: 'MyHotlist',
+ summary: 'the best hotlist',
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('saving refreshes issue hotlises if viewed issue is updated', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ created: {name: 'MyHotlist', summary: 'the best hotlist'},
+ }));
+ element.issueRefs = [
+ {localId: 22, projectName: 'test'},
+ {localId: 32, projectName: 'test'},
+ ];
+ element.viewedIssueRef = {localId: 32, projectName: 'test'};
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'ListHotlistsByIssue', {issue: {localId: 32, projectName: 'test'}});
+ });
+
+ it('dispatches event upon successfully saving', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+
+ await element.save();
+
+ sinon.assert.calledOnce(savedStub);
+ });
+
+ it('dispatches no event upon error saving', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ const error = new Error('Mistakes were made');
+ prpcClient.call.returns(Promise.reject(error));
+
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+
+ await element.save();
+
+ sinon.assert.notCalled(savedStub);
+ });
+});
diff --git a/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js
new file mode 100644
index 0000000..690bd6a
--- /dev/null
+++ b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js
@@ -0,0 +1,87 @@
+// 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';
+
+/**
+ * `<mr-crbug-link>`
+ *
+ * Displays a crbug short-link to an issue.
+ *
+ */
+export class MrCrbugLink extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ /**
+ * CSS variables provided to allow conditionally hiding <mr-crbug-link>
+ * in a way that's screenreader friendly.
+ */
+ --mr-crbug-link-opacity: 1;
+ --mr-crbug-link-opacity-focused: 1;
+ }
+ a.material-icons {
+ font-size: var(--chops-icon-font-size);
+ display: inline-block;
+ color: var(--chops-primary-icon-color);
+ padding: 0 2px;
+ box-sizing: border-box;
+ text-decoration: none;
+ vertical-align: middle;
+ }
+ a {
+ opacity: var(--mr-crbug-link-opacity);
+ }
+ a:focus {
+ opacity: var(--mr-crbug-link-opacity-focused);
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <a
+ id="bugLink"
+ class="material-icons"
+ href=${this._issueUrl}
+ title="crbug link"
+ >link</a>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * The issue being viewed. Falls back gracefully if this is only a ref.
+ */
+ issue: {type: Object},
+ };
+ }
+
+ /**
+ * Computes the URL to render in the shortlink.
+ * @return {string}
+ */
+ get _issueUrl() {
+ const issue = this.issue;
+ if (!issue) return '';
+ if (this._getHost() === 'bugs.chromium.org') {
+ const projectPart = (
+ issue.projectName == 'chromium' ? '' : issue.projectName + '/');
+ return `https://crbug.com/${projectPart}${issue.localId}`;
+ }
+ const issueType = issue.approvalValues ? 'approval' : 'detail';
+ return `/p/${issue.projectName}/issues/${issueType}?id=${issue.localId}`;
+ }
+
+ _getHost() {
+ // This function allows us to mock the host in unit testing.
+ return document.location.host;
+ }
+}
+customElements.define('mr-crbug-link', MrCrbugLink);
diff --git a/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js
new file mode 100644
index 0000000..aa7f21f
--- /dev/null
+++ b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js
@@ -0,0 +1,62 @@
+// 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 {MrCrbugLink} from './mr-crbug-link.js';
+
+
+let element;
+
+describe('mr-crbug-link', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-crbug-link');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrCrbugLink);
+ });
+
+ it('In prod, link to crbug.com with project name specified', async () => {
+ element._getHost = () => 'bugs.chromium.org';
+ element.issue = {
+ projectName: 'test',
+ localId: 11,
+ };
+
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.equal(link.href, 'https://crbug.com/test/11');
+ });
+
+ it('In prod, link to crbug.com with implicit project name', async () => {
+ element._getHost = () => 'bugs.chromium.org';
+ element.issue = {
+ projectName: 'chromium',
+ localId: 11,
+ };
+
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.equal(link.href, 'https://crbug.com/11');
+ });
+
+ it('does not redirects to approval page for regular issues', async () => {
+ element.issue = {
+ projectName: 'test',
+ localId: 11,
+ };
+
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.include(link.href.trim(), '/p/test/issues/detail?id=11');
+ });
+});
diff --git a/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js
new file mode 100644
index 0000000..1f8b01a
--- /dev/null
+++ b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js
@@ -0,0 +1,39 @@
+// 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} from 'lit-element';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-hotlist-link>`
+ *
+ * Displays a link to a hotlist.
+ *
+ */
+export class MrHotlistLink extends LitElement {
+ /** @override */
+ static get styles() {
+ return SHARED_STYLES;
+ }
+
+ /** @override */
+ render() {
+ if (!this.hotlist) return html``;
+ return html`
+ <a
+ href="/u/${this.hotlist.ownerRef && this.hotlist.ownerRef.userId}/hotlists/${this.hotlist.name}"
+ title="${this.hotlist.name} - ${this.hotlist.summary}"
+ >
+ ${this.hotlist.name}</a>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ hotlist: {type: Object},
+ };
+ }
+}
+customElements.define('mr-hotlist-link', MrHotlistLink);
diff --git a/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js
new file mode 100644
index 0000000..7071b77
--- /dev/null
+++ b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js
@@ -0,0 +1,23 @@
+// 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 {MrHotlistLink} from './mr-hotlist-link.js';
+
+let element;
+
+describe('mr-hotlist-link', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-hotlist-link');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrHotlistLink);
+ });
+});
diff --git a/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js
new file mode 100644
index 0000000..029de6c
--- /dev/null
+++ b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js
@@ -0,0 +1,119 @@
+// 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 {ifDefined} from 'lit-html/directives/if-defined';
+import {issueRefToString, issueRefToUrl} from 'shared/convertersV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import '../../mr-dropdown/mr-dropdown.js';
+import '../../../help/mr-cue/mr-fed-ref-cue.js';
+
+/**
+ * `<mr-issue-link>`
+ *
+ * Displays a link to an issue.
+ *
+ */
+export class MrIssueLink extends LitElement {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ a[is-closed] {
+ text-decoration: line-through;
+ }
+ mr-dropdown {
+ width: var(--chops-main-font-size);
+ --mr-dropdown-icon-font-size: var(--chops-main-font-size);
+ --mr-dropdown-menu-min-width: 100px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ let fedRefInfo;
+ if (this.issue && this.issue.extIdentifier) {
+ fedRefInfo = html`
+ <!-- TODO(jeffcarp): Figure out CSS to enable menuAlignment=left -->
+ <mr-dropdown
+ label="Federated Reference Info"
+ icon="info_outline"
+ menuAlignment="right"
+ >
+ <mr-fed-ref-cue
+ cuePrefName="federated_reference"
+ fedRefShortlink=${this.issue.extIdentifier}
+ nondismissible>
+ </mr-fed-ref-cue>
+ </mr-dropdown>
+ `;
+ }
+ return html`
+ <a
+ id="bugLink"
+ href=${this.href}
+ title=${ifDefined(this.issue && this.issue.summary)}
+ ?is-closed=${this.isClosed}
+ >${this._linkText}</a>${fedRefInfo}`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ // The issue being viewed. Falls back gracefully if this is only a ref.
+ issue: {type: Object},
+ text: {type: String},
+ // The global current project name. NOT the issue's project name.
+ projectName: {type: String},
+ queryParams: {type: Object},
+ short: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.issue = {};
+ this.queryParams = {};
+ this.short = false;
+ }
+
+ click() {
+ const link = this.shadowRoot.querySelector('a');
+ if (!link) return;
+ link.click();
+ }
+
+ /**
+ * @return {string} Where this issue links to.
+ */
+ get href() {
+ return issueRefToUrl(this.issue, this.queryParams);
+ }
+
+ get isClosed() {
+ if (!this.issue || !this.issue.statusRef) return false;
+
+ return this.issue.statusRef.meansOpen === false;
+ }
+
+ get _linkText() {
+ const {projectName, issue, text, short} = this;
+ if (text) return text;
+
+ if (issue && issue.extIdentifier) {
+ return issue.extIdentifier;
+ }
+
+ const prefix = short ? '' : 'Issue ';
+
+ return prefix + issueRefToString(issue, projectName);
+ }
+}
+
+customElements.define('mr-issue-link', MrIssueLink);
diff --git a/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js
new file mode 100644
index 0000000..1bd3ae9
--- /dev/null
+++ b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js
@@ -0,0 +1,147 @@
+// 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 {MrIssueLink} from './mr-issue-link.js';
+
+let element;
+
+describe('mr-issue-link', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-link');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueLink);
+ });
+
+ it('strikethrough when closed', async () => {
+ await element.updateComplete;
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.isFalse(
+ window.getComputedStyle(link).getPropertyValue(
+ 'text-decoration').includes('line-through'));
+ element.issue = {statusRef: {meansOpen: false}};
+
+ await element.updateComplete;
+
+ assert.isTrue(
+ window.getComputedStyle(link).getPropertyValue(
+ 'text-decoration').includes('line-through'));
+ });
+
+ it('shortens link text when short is true', () => {
+ element.issue = {
+ projectName: 'test',
+ localId: 13,
+ };
+
+ assert.equal(element._linkText, 'Issue test:13');
+
+ element.short = true;
+
+ assert.equal(element._linkText, 'test:13');
+ });
+
+ it('shows projectName only when different from global', async () => {
+ element.issue = {
+ projectName: 'test',
+ localId: 11,
+ };
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.equal(link.textContent.trim(), 'Issue test:11');
+
+ element.projectName = 'test';
+ await element.updateComplete;
+
+ assert.equal(link.textContent.trim(), 'Issue 11');
+
+ element.projectName = 'other';
+ await element.updateComplete;
+
+ await element.updateComplete;
+
+ assert.equal(link.textContent.trim(), 'Issue test:11');
+ });
+
+ it('shows links for issues', async () => {
+ element.issue = {
+ projectName: 'test',
+ localId: 11,
+ };
+
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.include(link.href.trim(), '/p/test/issues/detail?id=11');
+ assert.equal(link.title, '');
+ });
+
+ it('shows links for federated issues', async () => {
+ element.issue = {
+ extIdentifier: 'b/5678',
+ };
+
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.include(link.href.trim(), 'https://issuetracker.google.com/issues/5678');
+ assert.equal(link.title, '');
+ });
+
+ it('displays an icon for federated references', async () => {
+ element.issue = {
+ extIdentifier: 'b/5678',
+ };
+
+ await element.updateComplete;
+
+ const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+ assert.isNotNull(dropdown);
+ const anchor = dropdown.shadowRoot.querySelector('.anchor');
+ assert.isNotNull(anchor);
+ assert.include(anchor.innerText, 'info_outline');
+ });
+
+ it('displays an info popup for federated references', async () => {
+ element.issue = {
+ extIdentifier: 'b/5678',
+ };
+
+ await element.updateComplete;
+
+ const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+ const anchor = dropdown.shadowRoot.querySelector('.anchor');
+ anchor.click();
+
+ await dropdown.updateComplete;
+
+ assert.isTrue(dropdown.opened);
+
+ const cue = dropdown.querySelector('mr-fed-ref-cue');
+ assert.isNotNull(cue);
+ const message = cue.shadowRoot.querySelector('#message');
+ assert.isNotNull(message);
+ assert.include(message.innerText, 'Buganizer issue tracker');
+ });
+
+ it('shows title when summary is defined', async () => {
+ element.issue = {
+ projectName: 'test',
+ localId: 11,
+ summary: 'Summary',
+ };
+
+ await element.updateComplete;
+ const link = element.shadowRoot.querySelector('#bugLink');
+ assert.equal(link.title, 'Summary');
+ });
+});
diff --git a/static_src/elements/framework/links/mr-user-link/mr-user-link.js b/static_src/elements/framework/links/mr-user-link/mr-user-link.js
new file mode 100644
index 0000000..c009f89
--- /dev/null
+++ b/static_src/elements/framework/links/mr-user-link/mr-user-link.js
@@ -0,0 +1,129 @@
+// 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 {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+
+const NULL_DISPLAY_NAME_VALUES = [EMPTY_FIELD_VALUE, 'a_deleted_user'];
+
+/**
+ * `<mr-user-link>`
+ *
+ * Displays a link to a user profile.
+ *
+ */
+export class MrUserLink extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: inline-block;
+ white-space: nowrap;
+ }
+ i.inline-icon {
+ font-size: var(--chops-icon-font-size);
+ color: #B71C1C;
+ vertical-align: bottom;
+ cursor: pointer;
+ }
+ i.inline-icon-unseen {
+ color: var(--chops-purple-700);
+ }
+ i.material-icons[hidden] {
+ display: none;
+ }
+ .availability-notice {
+ color: #B71C1C;
+ font-weight: bold;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ referencedUsers: {
+ type: Object,
+ },
+ showAvailabilityIcon: {
+ type: Boolean,
+ },
+ showAvailabilityText: {
+ type: Boolean,
+ },
+ userRef: {
+ type: Object,
+ attribute: 'userref',
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.userRef = {};
+ this.referencedUsers = new Map();
+ this.showAvailabilityIcon = false;
+ this.showAvailabilityText = false;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.referencedUsers = issueV0.referencedUsers(state);
+ }
+
+ /** @override */
+ render() {
+ const availability = this._getAvailability();
+ const userLink = this._getUserLink();
+ const user = this.referencedUsers.get(this.userRef.displayName) || {};
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <i
+ id="availability-icon"
+ class="material-icons inline-icon ${user.last_visit_timestamp ? "" : "inline-icon-unseen"}"
+ title="${availability}"
+ ?hidden="${!(this.showAvailabilityIcon && availability)}"
+ >schedule</i>
+ <a
+ id="user-link"
+ href="${userLink}"
+ title="${this.userRef.displayName}"
+ ?hidden="${!userLink}"
+ >${this.userRef.displayName}</a>
+ <span
+ id="user-text"
+ ?hidden="${userLink}"
+ >${this.userRef.displayName}</span>
+ <div
+ id="availability-text"
+ class="availability-notice"
+ title="${availability}"
+ ?hidden="${!(this.showAvailabilityText && availability)}"
+ >${availability}</div>
+ `;
+ }
+
+ _getAvailability() {
+ if (!this.userRef || !this.referencedUsers) return '';
+ const user = this.referencedUsers.get(this.userRef.displayName) || {};
+ return user.availability;
+ }
+
+ _getUserLink() {
+ if (!this.userRef || !this.userRef.displayName ||
+ NULL_DISPLAY_NAME_VALUES.includes(this.userRef.displayName)) return '';
+ return `/u/${this.userRef.userId || this.userRef.displayName}`;
+ }
+}
+customElements.define('mr-user-link', MrUserLink);
diff --git a/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js b/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js
new file mode 100644
index 0000000..77af246
--- /dev/null
+++ b/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js
@@ -0,0 +1,156 @@
+// 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 {MrUserLink} from './mr-user-link.js';
+
+
+let element;
+let availabilityIcon;
+let userLink;
+let userText;
+let availabilityText;
+
+function getElements() {
+ availabilityIcon = element.shadowRoot.querySelector(
+ '#availability-icon');
+ userLink = element.shadowRoot.querySelector(
+ '#user-link');
+ userText = element.shadowRoot.querySelector(
+ '#user-text');
+ availabilityText = element.shadowRoot.querySelector(
+ '#availability-text');
+}
+
+describe('mr-user-link', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-user-link');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrUserLink);
+ });
+
+ it('no link when no userId and displayName is null value', async () => {
+ element.userRef = {displayName: '----'};
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isFalse(userText.hidden);
+ assert.equal(userText.textContent, '----');
+
+ assert.isTrue(availabilityIcon.hidden);
+ assert.isTrue(userLink.hidden);
+ assert.isTrue(availabilityText.hidden);
+ });
+
+ it('link when displayName', async () => {
+ element.userRef = {displayName: 'test@example.com'};
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isFalse(userLink.hidden);
+ assert.equal(userLink.textContent.trim(), 'test@example.com');
+ assert.isTrue(userLink.href.endsWith('/u/test@example.com'));
+
+ assert.isTrue(availabilityIcon.hidden);
+ assert.isTrue(userText.hidden);
+ assert.isTrue(availabilityText.hidden);
+ });
+
+ it('link when userId', async () => {
+ element.userRef = {userId: '1234', displayName: 'test@example.com'};
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isFalse(userLink.hidden);
+ assert.equal(userLink.textContent.trim(), 'test@example.com');
+ assert.isTrue(userLink.href.endsWith('/u/1234'));
+
+ assert.isTrue(availabilityIcon.hidden);
+ assert.isTrue(userText.hidden);
+ assert.isTrue(availabilityText.hidden);
+ });
+
+ it('show availability', async () => {
+ element.userRef = {userId: '1234', displayName: 'test@example.com'};
+ element.referencedUsers = new Map(
+ [['test@example.com', {availability: 'foo'}]]);
+ element.showAvailabilityIcon = true;
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isFalse(availabilityIcon.hidden);
+ assert.equal(availabilityIcon.title, 'foo');
+
+ assert.isFalse(userLink.hidden);
+ assert.isTrue(userText.hidden);
+ assert.isTrue(availabilityText.hidden);
+ });
+
+ it('dont show availability', async () => {
+ element.userRef = {userId: '1234', displayName: 'test@example.com'};
+ element.referencedUsers = new Map(
+ [['test@example.com', {availability: 'foo'}]]);
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isTrue(availabilityIcon.hidden);
+
+ assert.isFalse(userLink.hidden);
+ assert.isTrue(userText.hidden);
+ assert.isTrue(availabilityText.hidden);
+ });
+
+ it('show availability text', async () => {
+ element.userRef = {userId: '1234', displayName: 'test@example.com'};
+ element.referencedUsers = new Map(
+ [['test@example.com', {availability: 'foo'}]]);
+ element.showAvailabilityText = true;
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isFalse(availabilityText.hidden);
+ assert.equal(availabilityText.title, 'foo');
+ assert.equal(availabilityText.textContent, 'foo');
+
+ assert.isTrue(availabilityIcon.hidden);
+ assert.isFalse(userLink.hidden);
+ assert.isTrue(userText.hidden);
+ });
+
+ it('show availability user never visited', async () => {
+ element.userRef = {userId: '1234', displayName: 'test@example.com'};
+ element.referencedUsers = new Map(
+ [['test@example.com', {last_visit_timestamp: undefined}]]);
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isTrue(availabilityIcon.classList.contains("inline-icon-unseen"));
+ });
+
+ it('show availability user visited', async () => {
+ element.userRef = {userId: '1234', displayName: 'test@example.com'};
+ element.referencedUsers = new Map(
+ [['test@example.com', {last_visit_timestamp: "35"}]]);
+
+ await element.updateComplete;
+ getElements();
+
+ assert.isTrue(availabilityIcon.classList.contains("inline-icon"));
+ assert.isFalse(availabilityIcon.classList.contains("inline-icon-unseen"));
+ });
+});
diff --git a/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js
new file mode 100644
index 0000000..c37eb42
--- /dev/null
+++ b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js
@@ -0,0 +1,105 @@
+// 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 {ChopsAutocomplete} from
+ 'elements/chops/chops-autocomplete/chops-autocomplete';
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {arrayDifference} from 'shared/helpers.js';
+import {userRefsToDisplayNames} from 'shared/convertersV0.js';
+
+
+/**
+ * `<mr-autocomplete>` displays an autocomplete input.
+ *
+ */
+export class MrAutocomplete extends connectStore(ChopsAutocomplete) {
+ /** @override */
+ static get properties() {
+ return {
+ ...ChopsAutocomplete.properties,
+ /**
+ * String for the name of autocomplete vocabulary used.
+ * Valid values:
+ * - 'project': Names of projects available to the current user.
+ * - 'member': All members in the current project a user is viewing.
+ * - 'owner': Similar to member, except with groups excluded.
+ *
+ * TODO(zhangtiff): Implement the following stores.
+ * - 'component': All components in the current project.
+ * - 'label': Well-known labels in the current project.
+ */
+ vocabularyName: {type: String},
+ /**
+ * Object where the keys are 'type' values and each value is an object
+ * with the format {strings, docDict, replacer}.
+ */
+ vocabularies: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.vocabularyName = '';
+ this.vocabularies = {};
+ }
+
+ /** @override */
+ stateChanged(state) {
+ const visibleMembers = projectV0.viewedVisibleMembers(state);
+ const userProjects = userV0.projects(state);
+ this.vocabularies = {
+ 'project': this._setupProjectVocabulary(userProjects),
+ 'member': this._setupMemberVocabulary(visibleMembers),
+ 'owner': this._setupOwnerVocabulary(visibleMembers),
+ };
+ }
+
+ // TODO(zhangtiff): Move this logic into selectors to prevent computing
+ // vocabularies for every single instance of autocomplete.
+ _setupProjectVocabulary(userProjects) {
+ const {ownerOf = [], memberOf = [], contributorTo = []} = userProjects;
+ const strings = [...ownerOf, ...memberOf, ...contributorTo];
+ return {strings};
+ }
+
+ _setupMemberVocabulary(visibleMembers) {
+ const {userRefs = []} = visibleMembers;
+ return {strings: userRefsToDisplayNames(userRefs)};
+ }
+
+ _setupOwnerVocabulary(visibleMembers) {
+ const {userRefs = [], groupRefs = []} = visibleMembers;
+ const groups = userRefsToDisplayNames(groupRefs);
+ const users = userRefsToDisplayNames(userRefs);
+
+ // Remove groups from the list of all members.
+ const owners = arrayDifference(users, groups);
+ return {strings: owners};
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('vocabularyName') ||
+ changedProperties.has('vocabularies')) {
+ if (this.vocabularyName in this.vocabularies) {
+ const props = this.vocabularies[this.vocabularyName];
+
+ this.strings = props.strings || [];
+ this.docDict = props.docDict || {};
+ this.replacer = props.replacer;
+ } else {
+ // Clear autocomplete if there's no data for it.
+ this.strings = [];
+ this.docDict = {};
+ this.replacer = null;
+ }
+ }
+
+ super.update(changedProperties);
+ }
+}
+customElements.define('mr-autocomplete', MrAutocomplete);
diff --git a/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js
new file mode 100644
index 0000000..0c4e3ae
--- /dev/null
+++ b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js
@@ -0,0 +1,86 @@
+// 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 {MrAutocomplete} from './mr-autocomplete.js';
+
+let element;
+
+describe('mr-autocomplete', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-autocomplete');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrAutocomplete);
+ });
+
+ it('sets properties based on vocabularies', async () => {
+ assert.deepEqual(element.strings, []);
+ assert.deepEqual(element.docDict, {});
+
+ element.vocabularies = {
+ 'project': {
+ 'strings': ['chromium', 'v8'],
+ 'docDict': {'chromium': 'move the web forward'},
+ },
+ };
+
+ element.vocabularyName = 'project';
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.strings, ['chromium', 'v8']);
+ assert.deepEqual(element.docDict, {'chromium': 'move the web forward'});
+ });
+
+ it('_setupProjectVocabulary', () => {
+ assert.deepEqual(element._setupProjectVocabulary({}), {strings: []});
+
+ assert.deepEqual(element._setupProjectVocabulary({
+ ownerOf: ['chromium'],
+ memberOf: ['skia'],
+ contributorTo: ['v8'],
+ }), {strings: ['chromium', 'skia', 'v8']});
+ });
+
+ it('_setupMemberVocabulary', () => {
+ assert.deepEqual(element._setupMemberVocabulary({}), {strings: []});
+
+ assert.deepEqual(element._setupMemberVocabulary({
+ userRefs: [
+ {displayName: 'group@example.com', userId: '100'},
+ {displayName: 'test@example.com', userId: '123'},
+ {displayName: 'test2@example.com', userId: '543'},
+ ],
+ groupRefs: [
+ {displayName: 'group@example.com', userId: '100'},
+ ],
+ }), {strings:
+ ['group@example.com', 'test@example.com', 'test2@example.com'],
+ });
+ });
+
+ it('_setupOwnerVocabulary', () => {
+ assert.deepEqual(element._setupOwnerVocabulary({}), {strings: []});
+
+ assert.deepEqual(element._setupOwnerVocabulary({
+ userRefs: [
+ {displayName: 'group@example.com', userId: '100'},
+ {displayName: 'test@example.com', userId: '123'},
+ {displayName: 'test2@example.com', userId: '543'},
+ ],
+ groupRefs: [
+ {displayName: 'group@example.com', userId: '100'},
+ ],
+ }), {strings:
+ ['test@example.com', 'test2@example.com'],
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-button-bar/mr-button-bar.js b/static_src/elements/framework/mr-button-bar/mr-button-bar.js
new file mode 100644
index 0000000..8cff503
--- /dev/null
+++ b/static_src/elements/framework/mr-button-bar/mr-button-bar.js
@@ -0,0 +1,100 @@
+// Copyright 2020 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/framework/mr-dropdown/mr-dropdown.js';
+
+import 'shared/typedef.js';
+
+/** Button bar containing table controls. */
+export class MrButtonBar extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: flex;
+ }
+ button {
+ background: none;
+ color: var(--chops-link-color);
+ cursor: pointer;
+ font-size: var(--chops-normal-font-size);
+ font-weight: var(--chops-link-font-weight);
+
+ line-height: 24px;
+ padding: 4px 16px;
+
+ border: none;
+
+ align-items: center;
+ display: inline-flex;
+ }
+ button:hover {
+ background: var(--chops-active-choice-bg);
+ }
+ i.material-icons {
+ font-size: 20px;
+ margin-right: 4px;
+ vertical-align: middle;
+ }
+ mr-dropdown {
+ --mr-dropdown-anchor-padding: 6px 4px;
+ --mr-dropdown-icon-color: var(--chops-link-color);
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ ${this.items.map(_renderItem)}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ items: {type: Array},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** @type {Array<MenuItem>} */
+ this.items = [];
+ }
+};
+
+/**
+ * Renders one item.
+ * @param {MenuItem} item
+ * @return {TemplateResult}
+ */
+function _renderItem(item) {
+ if (item.items) {
+ return html`
+ <mr-dropdown
+ icon=${item.icon}
+ menuAlignment="left"
+ label=${item.text}
+ .items=${item.items}
+ ></mr-dropdown>
+ `;
+ } else {
+ return html`
+ <button @click=${item.handler}>
+ <i class="material-icons" ?hidden=${!item.icon}>
+ ${item.icon}
+ </i>
+ ${item.text}
+ </button>
+ `;
+ }
+}
+
+customElements.define('mr-button-bar', MrButtonBar);
diff --git a/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js b/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js
new file mode 100644
index 0000000..349a8df
--- /dev/null
+++ b/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js
@@ -0,0 +1,53 @@
+// Copyright 2020 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 {MrButtonBar} from './mr-button-bar.js';
+
+/** @type {MrButtonBar} */
+let element;
+
+describe('mr-button-bar', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ element = document.createElement('mr-button-bar');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrButtonBar);
+ });
+
+ it('renders button items', async () => {
+ const handler = sinon.stub();
+
+ element.items = [{icon: 'emoji_nature', text: 'Pollinate', handler}];
+ await element.updateComplete;
+
+ const button = element.shadowRoot.querySelector('button');
+ button.click();
+
+ assert.include(button.innerHTML, 'emoji_nature');
+ assert.include(button.innerHTML, 'Pollinate');
+ sinon.assert.calledOnce(handler);
+ });
+
+ it('renders dropdown items', async () => {
+ const items = [{icon: 'emoji_nature', text: 'Pollinate'}];
+ element.items = [{icon: 'more_vert', text: 'More actions...', items}];
+ await element.updateComplete;
+
+ /** @type {MrDropdown} */
+ const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+ assert.strictEqual(dropdown.icon, 'more_vert');
+ assert.strictEqual(dropdown.label, 'More actions...');
+ assert.strictEqual(dropdown.items, items);
+ });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.js b/static_src/elements/framework/mr-comment-content/mr-attachment.js
new file mode 100644
index 0000000..c435dfd
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.js
@@ -0,0 +1,206 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles.js';
+import {FILE_DOWNLOAD_WARNING, ALLOWED_ATTACHMENT_EXTENSIONS,
+ ALLOWED_CONTENT_TYPE_PREFIXES} from 'shared/settings.js';
+import 'elements/chops/chops-button/chops-button.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/**
+ * `<mr-attachment>`
+ *
+ * Display attachments for Monorail comments.
+ *
+ */
+export class MrAttachment extends connectStore(LitElement) {
+ /** @override */
+ static get properties() {
+ return {
+ attachment: {type: Object},
+ projectName: {type: String},
+ localId: {type: Number},
+ sequenceNum: {type: Number},
+ canDelete: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ .attachment-view,
+ .attachment-download {
+ margin-left: 8px;
+ display: block;
+ }
+ .attachment-delete {
+ margin-left: 16px;
+ color: var(--chops-button-color);
+ background: var(--chops-button-bg);
+ border-color: transparent;
+ }
+ .comment-attachment {
+ min-width: 20%;
+ width: fit-content;
+ background: var(--chops-card-details-bg);
+ padding: 4px;
+ margin: 8px;
+ overflow: auto;
+ }
+ .comment-attachment-header {
+ display: flex;
+ flex-wrap: nowrap;
+ }
+ .filename {
+ margin-left: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ .filename-deleted {
+ margin-right: 4px;
+ }
+ .filesize {
+ margin-left: 8px;
+ white-space: nowrap;
+ }
+ .preview {
+ border: 2px solid #c3d9ff;
+ padding: 1px;
+ max-width: 98%;
+ }
+ .preview:hover {
+ border: 2px solid blue;
+ }
+ `];
+ }
+
+
+ /** @override */
+ render() {
+ return html`
+ <div class="comment-attachment">
+ <div class="filename">
+ ${this.attachment.isDeleted ? html`
+ <div class="filename-deleted">[Deleted]</div>
+ ` : ''}
+ <b>${this.attachment.filename}</b>
+ ${this.canDelete ? html`
+ <chops-button
+ class="attachment-delete"
+ @click=${this._deleteAttachment}>
+ ${this.attachment.isDeleted ? 'Undelete' : 'Delete'}
+ </chops-button>
+ ` : ''}
+ </div>
+ ${!this.attachment.isDeleted ? html`
+ <div class="comment-attachment-header">
+ <div class="filesize">${_bytesOrKbOrMb(this.attachment.size)}</div>
+ ${this.attachment.viewUrl ? html`
+ <a
+ class="attachment-view"
+ href=${this.attachment.viewUrl}
+ target="_blank"
+ >View</a>
+ `: ''}
+ <a
+ class="attachment-download"
+ href=${this.attachment.downloadUrl}
+ target="_blank"
+ ?hidden=${!this.attachment.downloadUrl}
+ @click=${this._warnOnDownload}
+ >Download</a>
+ </div>
+ ${this.attachment.thumbnailUrl ? html`
+ <a href=${this.attachment.viewUrl} target="_blank">
+ <img
+ class="preview" alt="attachment preview"
+ src=${this.attachment.thumbnailUrl}>
+ </a>
+ ` : ''}
+ ${_isVideo(this.attachment.contentType) ? html`
+ <video
+ src=${this.attachment.viewUrl}
+ class="preview"
+ controls
+ width="640"
+ preload="metadata"
+ ></video>
+ ` : ''}
+ ` : ''}
+ </div>
+ `;
+ }
+
+ /**
+ * Deletes a given attachment in a comment.
+ */
+ _deleteAttachment() {
+ const issueRef = {
+ projectName: this.projectName,
+ localId: this.localId,
+ };
+
+ const promise = prpcClient.call(
+ 'monorail.Issues', 'DeleteAttachment',
+ {
+ issueRef,
+ sequenceNum: this.sequenceNum,
+ attachmentId: this.attachment.attachmentId,
+ delete: !this.attachment.isDeleted,
+ });
+
+ promise.then(() => {
+ store.dispatch(issueV0.fetchComments(issueRef));
+ }, (error) => {
+ console.log('Failed to (un)delete attachment', error);
+ });
+ }
+
+ /**
+ * Give the user a warning before they download files that Monorail thinks
+ * might have the potential to be unsafe.
+ * @param {MouseEvent} e
+ */
+ _warnOnDownload(e) {
+ const isAllowedType = ALLOWED_CONTENT_TYPE_PREFIXES.some((prefix) => {
+ return this.attachment.contentType.startsWith(prefix);
+ });
+ const isAllowedExtension = ALLOWED_ATTACHMENT_EXTENSIONS.some((ext) => {
+ return this.attachment.filename.toLowerCase().endsWith(ext);
+ });
+
+ if (isAllowedType || isAllowedExtension) return;
+ if (!window.confirm(FILE_DOWNLOAD_WARNING)) {
+ e.preventDefault();
+ }
+ }
+}
+
+function _isVideo(contentType) {
+ if (!contentType) return;
+ return contentType.startsWith('video/');
+}
+
+function _bytesOrKbOrMb(numBytes) {
+ if (numBytes < 1024) {
+ return `${numBytes} bytes`; // e.g., 128 bytes
+ } else if (numBytes < 99 * 1024) {
+ return `${(numBytes / 1024).toFixed(1)} KB`; // e.g. 23.4 KB
+ } else if (numBytes < 1024 * 1024) {
+ return `${(numBytes / 1024).toFixed(0)} KB`; // e.g., 219 KB
+ } else if (numBytes < 99 * 1024 * 1024) {
+ return `${(numBytes / 1024 / 1024).toFixed(1)} MB`; // e.g., 21.9 MB
+ } else {
+ return `${(numBytes / 1024 / 1024).toFixed(0)} MB`; // e.g., 100 MB
+ }
+}
+
+customElements.define('mr-attachment', MrAttachment);
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.test.js b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
new file mode 100644
index 0000000..ec79c66
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
@@ -0,0 +1,228 @@
+// 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, expect} from 'chai';
+import {MrAttachment} from './mr-attachment.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {FILE_DOWNLOAD_WARNING} from 'shared/settings.js';
+
+let element;
+
+describe('mr-attachment', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-attachment');
+ document.body.appendChild(element);
+ sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrAttachment);
+ });
+
+ it('shows image thumbnail', async () => {
+ element.attachment = {
+ thumbnailUrl: 'thumbnail.jpeg',
+ contentType: 'image/jpeg',
+ };
+ await element.updateComplete;
+ const img = element.shadowRoot.querySelector('img');
+ assert.isNotNull(img);
+ assert.isTrue(img.src.endsWith('thumbnail.jpeg'));
+ });
+
+ it('shows video thumbnail', async () => {
+ element.attachment = {
+ viewUrl: 'video.mp4',
+ contentType: 'video/mpeg',
+ };
+ await element.updateComplete;
+ const video = element.shadowRoot.querySelector('video');
+ assert.isNotNull(video);
+ assert.isTrue(video.src.endsWith('video.mp4'));
+ });
+
+ it('does not show image thumbnail if deleted', async () => {
+ element.attachment = {
+ thumbnailUrl: 'thumbnail.jpeg',
+ contentType: 'image/jpeg',
+ isDeleted: true,
+ };
+ await element.updateComplete;
+ const img = element.shadowRoot.querySelector('img');
+ assert.isNull(img);
+ });
+
+ it('does not show video thumbnail if deleted', async () => {
+ element.attachment = {
+ viewUrl: 'video.mp4',
+ contentType: 'video/mpeg',
+ isDeleted: true,
+ };
+ await element.updateComplete;
+ const video = element.shadowRoot.querySelector('video');
+ assert.isNull(video);
+ });
+
+ it('deletes attachment', async () => {
+ prpcClient.call.callsFake(() => Promise.resolve({}));
+
+ element.attachment = {
+ attachmentId: 67890,
+ isDeleted: false,
+ };
+ element.canDelete = true;
+ element.projectName = 'proj';
+ element.localId = 1234;
+ element.sequenceNum = 3;
+ await element.updateComplete;
+
+ const deleteButton = element.shadowRoot.querySelector('chops-button');
+ deleteButton.click();
+
+ assert.deepEqual(prpcClient.call.getCall(0).args, [
+ 'monorail.Issues', 'DeleteAttachment',
+ {
+ issueRef: {
+ projectName: 'proj',
+ localId: 1234,
+ },
+ sequenceNum: 3,
+ attachmentId: 67890,
+ delete: true,
+ },
+ ]);
+ assert.isTrue(prpcClient.call.calledOnce);
+ });
+
+ it('undeletes attachment', async () => {
+ prpcClient.call.callsFake(() => Promise.resolve({}));
+ element.attachment = {
+ attachmentId: 67890,
+ isDeleted: true,
+ };
+ element.canDelete = true;
+ element.projectName = 'proj';
+ element.localId = 1234;
+ element.sequenceNum = 3;
+ await element.updateComplete;
+
+ const deleteButton = element.shadowRoot.querySelector('chops-button');
+ deleteButton.click();
+
+ assert.deepEqual(prpcClient.call.getCall(0).args, [
+ 'monorail.Issues', 'DeleteAttachment',
+ {
+ issueRef: {
+ projectName: 'proj',
+ localId: 1234,
+ },
+ sequenceNum: 3,
+ attachmentId: 67890,
+ delete: false,
+ },
+ ]);
+ assert.isTrue(prpcClient.call.calledOnce);
+ });
+
+ it('view link is not displayed if not given', async () => {
+ element.attachment = {};
+ await element.updateComplete;
+ const viewLink = element.shadowRoot.querySelector('.attachment-view');
+ assert.isNull(viewLink);
+ });
+
+ it('view link is displayed if given', async () => {
+ element.attachment = {
+ viewUrl: 'http://example.com/attachment.foo',
+ };
+ await element.updateComplete;
+ const viewLink = element.shadowRoot.querySelector('.attachment-view');
+ assert.isNotNull(viewLink);
+ expect(viewLink).to.be.displayed;
+ assert.equal(viewLink.href, 'http://example.com/attachment.foo');
+ });
+
+ describe('download', () => {
+ let downloadLink;
+
+ beforeEach(async () => {
+ sinon.stub(window, 'confirm').returns(false);
+
+
+ element.attachment = {};
+ await element.updateComplete;
+ downloadLink = element.shadowRoot.querySelector('.attachment-download');
+ // Prevent Karma from opening up new tabs because of simulated link
+ // clicks.
+ downloadLink.removeAttribute('target');
+ });
+
+ afterEach(() => {
+ window.confirm.restore();
+ });
+
+ it('download link is not displayed if not given', async () => {
+ element.attachment = {};
+ await element.updateComplete;
+ assert.isTrue(downloadLink.hidden);
+ });
+
+ it('download link is displayed if given', async () => {
+ element.attachment = {
+ downloadUrl: 'http://example.com/attachment.foo',
+ };
+ await element.updateComplete;
+ const downloadLink = element.shadowRoot.querySelector(
+ '.attachment-download');
+ assert.isFalse(downloadLink.hidden);
+ expect(downloadLink).to.be.displayed;
+ assert.equal(downloadLink.href, 'http://example.com/attachment.foo');
+ });
+
+ it('download allows recognized file extension and type', async () => {
+ element.attachment = {
+ contentType: 'image/png',
+ filename: 'not-a-virus.png',
+ downloadUrl: '#',
+ };
+ await element.updateComplete;
+
+ downloadLink.click();
+
+ sinon.assert.notCalled(window.confirm);
+ });
+
+ it('file extension matching is case insensitive', async () => {
+ element.attachment = {
+ contentType: 'image/png',
+ filename: 'not-a-virus.PNG',
+ downloadUrl: '#',
+ };
+ await element.updateComplete;
+
+ downloadLink.click();
+
+ sinon.assert.notCalled(window.confirm);
+ });
+
+ it('download warns on unrecognized file extension and type', async () => {
+ element.attachment = {
+ contentType: 'application/virus',
+ filename: 'fake-virus.exe',
+ downloadUrl: '#',
+ };
+ await element.updateComplete;
+
+ downloadLink.click();
+
+ sinon.assert.calledOnce(window.confirm);
+ sinon.assert.calledWith(window.confirm, FILE_DOWNLOAD_WARNING);
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
new file mode 100644
index 0000000..c2bf3e8
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
@@ -0,0 +1,131 @@
+// 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 {ifDefined} from 'lit-html/directives/if-defined';
+import {autolink} from 'autolink.js';
+import {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 {SHARED_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+import {shouldRenderMarkdown, renderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+
+/**
+ * `<mr-comment-content>`
+ *
+ * Displays text for a comment.
+ *
+ */
+export class MrCommentContent extends connectStore(LitElement) {
+ /** @override */
+ constructor() {
+ super();
+
+ this.content = '';
+ this.commentReferences = new Map();
+ this.isDeleted = false;
+ this.projectName = '';
+ this.author = '';
+ this.prefs = {};
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ content: {type: String},
+ commentReferences: {type: Object},
+ revisionUrlFormat: {type: String},
+ isDeleted: {
+ type: Boolean,
+ reflect: true,
+ },
+ projectName: {type: String},
+ author: {type: String},
+ prefs: {type: Object},
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ MD_STYLES,
+ css`
+ :host {
+ word-break: break-word;
+ font-size: var(--chops-main-font-size);
+ line-height: 130%;
+ font-family: var(--mr-toggled-font-family);
+ }
+ :host([isDeleted]) {
+ color: #888;
+ font-style: italic;
+ }
+ .line {
+ white-space: pre-wrap;
+ }
+ .strike-through {
+ text-decoration: line-through;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ if (shouldRenderMarkdown({project: this.projectName, author: this.author,
+ enabled: this._renderMarkdown})) {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <div class="markdown">
+ ${unsafeHTML(renderMarkdown(this.content))}
+ </div>
+ `;
+ }
+ const runs = autolink.markupAutolinks(
+ this.content, this.commentReferences, this.projectName,
+ this.revisionUrlFormat);
+ const templates = runs.map((run) => {
+ switch (run.tag) {
+ case 'b':
+ return html`<b class="line">${run.content}</b>`;
+ case 'br':
+ return html`<br>`;
+ case 'a':
+ return html`<a
+ class="line"
+ target="_blank"
+ href=${run.href}
+ class=${run.css}
+ title=${ifDefined(run.title)}
+ >${run.content}</a>`;
+ default:
+ return html`<span class="line">${run.content}</span>`;
+ }
+ });
+ return html`${templates}`;
+ }
+
+ /**
+ * Helper to get state of Markdown rendering.
+ * @return {boolean} Whether to render Markdown.
+ */
+ get _renderMarkdown() {
+ const {prefs} = this;
+ if (!prefs) return true;
+ return prefs.get('render_markdown');
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.commentReferences = issueV0.commentReferences(state);
+ this.projectName = issueV0.viewedIssueRef(state).projectName;
+ this.revisionUrlFormat =
+ projectV0.viewedPresentationConfig(state).revisionUrlFormat;
+ this.prefs = userV0.prefs(state);
+ }
+}
+customElements.define('mr-comment-content', MrCommentContent);
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
new file mode 100644
index 0000000..4eeaab5
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
@@ -0,0 +1,84 @@
+// 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 {MrCommentContent} from './mr-comment-content.js';
+
+
+let element;
+
+describe('mr-comment-content', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-comment-content');
+ document.body.appendChild(element);
+
+ document.body.style.setProperty('--mr-toggled-font-family', 'Some-font');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ document.body.style.removeProperty('--mr-toggled-font-family');
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrCommentContent);
+ });
+
+ it('changes rendered font based on --mr-toggled-font-family', async () => {
+ element.content = 'A comment';
+
+ await element.updateComplete;
+
+ const fontFamily = window.getComputedStyle(element).getPropertyValue(
+ 'font-family');
+
+ assert.equal(fontFamily, 'Some-font');
+ });
+
+ it('does not render spurious spaces', async () => {
+ element.content =
+ 'Some text before a go/link and more text before <b>some bold text</b>.';
+
+ await element.updateComplete;
+
+ const textContents = Array.from(element.shadowRoot.children).map(
+ (child) => child.textContent);
+
+ assert.deepEqual(textContents, [
+ 'Some text before a',
+ ' ',
+ 'go/link',
+ ' and more text before ',
+ 'some bold text',
+ '.',
+ ]);
+
+ assert.deepEqual(
+ element.shadowRoot.textContent,
+ 'Some text before a go/link and more text before some bold text.');
+ });
+
+ it('does render markdown', async () => {
+ element.prefs = new Map([['render_markdown', true]]);
+ element.content = '### this is a header';
+ element.projectName = 'monkeyrail';
+
+ await element.updateComplete;
+
+ const headerText = element.shadowRoot.querySelector('h3').textContent;
+ assert.equal(headerText, 'this is a header');
+ });
+
+ it('does not render markdown when prefs are set to false', async () => {
+ element.prefs = new Map([['render_markdown', false]]);
+ element.projectName = 'monkeyrail';
+ element.content = '### this is a header';
+
+ await element.updateComplete;
+
+ const commentText = element.shadowRoot.textContent;
+ assert.equal(commentText, '### this is a header');
+ });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.js b/static_src/elements/framework/mr-comment-content/mr-description.js
new file mode 100644
index 0000000..89ae105
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.js
@@ -0,0 +1,137 @@
+// 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 './mr-comment-content.js';
+import './mr-attachment.js';
+
+import {relativeTime} from
+ 'elements/chops/chops-timestamp/chops-timestamp-helpers';
+
+
+/**
+ * `<mr-description>`
+ *
+ * Element for displaying a description or survey.
+ *
+ */
+export class MrDescription extends LitElement {
+ /** @override */
+ constructor() {
+ super();
+
+ this.descriptionList = [];
+ this.selectedIndex = 0;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ descriptionList: {type: Array},
+ selectedIndex: {type: Number},
+ };
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('descriptionList')) {
+ if (!this.descriptionList || !this.descriptionList.length) return;
+ this.selectedIndex = this.descriptionList.length - 1;
+ }
+ }
+
+ /** @override */
+ static get styles() {
+ return css`
+ .select-container {
+ text-align: right;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ const selectedDescription = this.selectedDescription;
+
+ return html`
+ <div class="select-container">
+ <select
+ @change=${this._selectChanged}
+ ?hidden=${!this.descriptionList || this.descriptionList.length <= 1}
+ aria-label="Description history menu">
+ ${this.descriptionList.map((desc, i) => this._renderDescriptionOption(desc, i))}
+ </select>
+ </div>
+ <mr-comment-content
+ .content=${selectedDescription.content}
+ .author=${selectedDescription.commenter.displayName}
+ ></mr-comment-content>
+ <div>
+ ${(selectedDescription.attachments || []).map((attachment) => html`
+ <mr-attachment
+ .attachment=${attachment}
+ .projectName=${selectedDescription.projectName}
+ .localId=${selectedDescription.localId}
+ .sequenceNum=${selectedDescription.sequenceNum}
+ .canDelete=${selectedDescription.canDelete}
+ ></mr-attachment>
+ `)}
+ </div>
+ `;
+ }
+
+ /**
+ * Getter for the currently viewed description.
+ * @return {Comment} The description object.
+ */
+ get selectedDescription() {
+ const descriptions = this.descriptionList || [];
+ const index = Math.max(
+ Math.min(this.selectedIndex, descriptions.length - 1),
+ 0);
+ return descriptions[index] || {};
+ }
+
+ /**
+ * Helper to render a <select> <option> for a single description, for our
+ * description selector.
+ * @param {Comment} description
+ * @param {Number} index
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderDescriptionOption(description, index) {
+ const {commenter, timestamp} = description || {};
+ const byLine = commenter ? `by ${commenter.displayName}` : '';
+ return html`
+ <option value=${index} ?selected=${index === this.selectedIndex}>
+ Description #${index + 1} ${byLine} (${_relativeTime(timestamp)})
+ </option>
+ `;
+ }
+
+ /**
+ * Updates the element's selectedIndex when the user changes the select menu.
+ * @param {Event} evt
+ */
+ _selectChanged(evt) {
+ if (!evt || !evt.target) return;
+ this.selectedIndex = Number.parseInt(evt.target.value);
+ }
+}
+
+/**
+ * Template helper for rendering relative time.
+ * @param {number} unixTime Unix timestamp in seconds.
+ * @return {string} human readable timestamp.
+ */
+function _relativeTime(unixTime) {
+ unixTime = Number.parseInt(unixTime);
+ if (Number.isNaN(unixTime)) return;
+ return relativeTime(new Date(unixTime * 1000));
+}
+
+customElements.define('mr-description', MrDescription);
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.test.js b/static_src/elements/framework/mr-comment-content/mr-description.test.js
new file mode 100644
index 0000000..9d39149
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.test.js
@@ -0,0 +1,81 @@
+// 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 {MrDescription} from './mr-description.js';
+
+
+let element;
+
+describe('mr-description', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-description');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrDescription);
+ });
+
+ it('changes rendered description on select change', async () => {
+ element.descriptionList = [
+ {content: 'description one', commenter: {displayName: 'name'}},
+ {content: 'description two', commenter: {displayName: 'name'}},
+ ];
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ const commentContent =
+ element.shadowRoot.querySelector('mr-comment-content');
+ assert.equal('description two', commentContent.content);
+
+ element.selectedIndex = 0;
+
+ await element.updateComplete;
+
+ assert.equal('description one', commentContent.content);
+ });
+
+ it('hides selector when only one description', async () => {
+ element.descriptionList = [
+ {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+ {content: 'rutabaga', commenter: {displayName: 'name@email.com'}},
+ ];
+
+ await element.updateComplete;
+
+ const selectMenu = element.shadowRoot.querySelector('select');
+ assert.isFalse(selectMenu.hidden);
+
+ element.descriptionList = [
+ {content: 'blehh', commenter: {displayName: 'name@email.com'}},
+ ];
+
+ await element.updateComplete;
+
+ assert.isTrue(selectMenu.hidden);
+ });
+
+ it('selector still renders when one description is deleted', async () => {
+ element.descriptionList = [
+ {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+ {isDeleted: true, commenter: {displayName: 'name@email.com'}},
+ ];
+
+ await element.updateComplete;
+
+ const selectMenu = element.shadowRoot.querySelector('select');
+ assert.isFalse(selectMenu.hidden);
+
+ const options = selectMenu.querySelectorAll('option');
+
+ assert.include(options[0].textContent, 'Description #1 by name@email.com');
+ assert.include(options[1].textContent, 'Description #2');
+ });
+});
diff --git a/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js
new file mode 100644
index 0000000..264b976
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js
@@ -0,0 +1,63 @@
+// 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 './mr-dropdown.js';
+
+/**
+ * `<mr-account-dropdown>`
+ *
+ * Account dropdown menu for Monorail.
+ *
+ */
+export class MrAccountDropdown extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ position: relative;
+ display: inline-block;
+ height: 100%;
+ font-size: inherit;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <mr-dropdown
+ .text=${this.userDisplayName}
+ .items=${this.items}
+ .icon="arrow_drop_down"
+ ></mr-dropdown>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ userDisplayName: String,
+ logoutUrl: String,
+ loginUrl: String,
+ };
+ }
+
+ get items() {
+ return [
+ {text: 'Switch accounts', url: this.loginUrl},
+ {separator: true},
+ {text: 'Profile', url: `/u/${this.userDisplayName}`},
+ {text: 'Updates', url: `/u/${this.userDisplayName}/updates`},
+ {text: 'Settings', url: '/hosting/settings'},
+ {text: 'Saved queries', url: `/u/${this.userDisplayName}/queries`},
+ {text: 'Hotlists', url: `/u/${this.userDisplayName}/hotlists`},
+ {separator: true},
+ {text: 'Sign out', url: this.logoutUrl},
+ ];
+ }
+}
+
+customElements.define('mr-account-dropdown', MrAccountDropdown);
diff --git a/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js
new file mode 100644
index 0000000..f365823
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js
@@ -0,0 +1,23 @@
+// 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 {MrAccountDropdown} from './mr-account-dropdown.js';
+
+let element;
+
+describe('mr-account-dropdown', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-account-dropdown');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrAccountDropdown);
+ });
+});
diff --git a/static_src/elements/framework/mr-dropdown/mr-dropdown.js b/static_src/elements/framework/mr-dropdown/mr-dropdown.js
new file mode 100644
index 0000000..4564ab0
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-dropdown.js
@@ -0,0 +1,367 @@
+// 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 {ifDefined} from 'lit-html/directives/if-defined';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'shared/typedef.js';
+
+export const SCREENREADER_ATTRIBUTE_ERROR = `For screenreader support,
+ mr-dropdown must always have either a label or a text property defined.`;
+
+/**
+ * `<mr-dropdown>`
+ *
+ * Dropdown menu for Monorail.
+ *
+ */
+export class MrDropdown extends LitElement {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ position: relative;
+ display: inline-block;
+ height: 100%;
+ font-size: inherit;
+ font-family: var(--chops-font-family);
+ --mr-dropdown-icon-color: var(--chops-primary-icon-color);
+ --mr-dropdown-icon-font-size: var(--chops-icon-font-size);
+ --mr-dropdown-anchor-font-weight: var(--chops-link-font-weight);
+ --mr-dropdown-anchor-padding: 4px 0.25em;
+ --mr-dropdown-anchor-justify-content: center;
+ --mr-dropdown-menu-max-height: initial;
+ --mr-dropdown-menu-overflow: initial;
+ --mr-dropdown-menu-min-width: 120%;
+ --mr-dropdown-menu-font-size: var(--chops-large-font-size);
+ --mr-dropdown-menu-icon-size: var(--chops-icon-font-size);
+ }
+ :host([hidden]) {
+ display: none;
+ visibility: hidden;
+ }
+ :host(:not([opened])) .menu {
+ display: none;
+ visibility: hidden;
+ }
+ strong {
+ font-size: var(--chops-large-font-size);
+ }
+ i.material-icons {
+ font-size: var(--mr-dropdown-icon-font-size);
+ display: inline-block;
+ color: var(--mr-dropdown-icon-color);
+ padding: 0 2px;
+ box-sizing: border-box;
+ }
+ i.material-icons[hidden],
+ .menu-item > i.material-icons[hidden] {
+ display: none;
+ }
+ .menu-item > i.material-icons {
+ display: block;
+ font-size: var(--mr-dropdown-menu-icon-size);
+ width: var(--mr-dropdown-menu-icon-size);
+ height: var(--mr-dropdown-menu-icon-size);
+ margin-right: 8px;
+ }
+ .anchor:disabled {
+ color: var(--chops-button-disabled-color);
+ }
+ button.anchor {
+ box-sizing: border-box;
+ background: none;
+ border: none;
+ font-size: inherit;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: var(--mr-dropdown-anchor-justify-content);
+ cursor: pointer;
+ padding: var(--mr-dropdown-anchor-padding);
+ color: var(--chops-link-color);
+ font-weight: var(--mr-dropdown-anchor-font-weight);
+ font-family: inherit;
+ }
+ /* menuAlignment options: right, left, side. */
+ .menu.right {
+ right: 0px;
+ }
+ .menu.left {
+ left: 0px;
+ }
+ .menu.side {
+ left: 100%;
+ top: 0;
+ }
+ .menu {
+ font-size: var(--mr-dropdown-menu-font-size);
+ position: absolute;
+ min-width: var(--mr-dropdown-menu-min-width);
+ max-height: var(--mr-dropdown-menu-max-height);
+ overflow: var(--mr-dropdown-menu-overflow);
+ top: 90%;
+ display: block;
+ background: var(--chops-white);
+ border: var(--chops-accessible-border);
+ z-index: 990;
+ box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
+ font-family: inherit;
+ }
+ .menu-item {
+ background: none;
+ margin: 0;
+ border: 0;
+ box-sizing: border-box;
+ text-decoration: none;
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ justify-content: left;
+ width: 100%;
+ padding: 0.25em 8px;
+ transition: 0.2s background ease-in-out;
+
+ }
+ .menu-item[hidden] {
+ display: none;
+ }
+ mr-dropdown.menu-item {
+ width: 100%;
+ padding: 0;
+ --mr-dropdown-anchor-padding: 0.25em 8px;
+ --mr-dropdown-anchor-justify-content: space-between;
+ }
+ .menu hr {
+ width: 96%;
+ margin: 0 2%;
+ border: 0;
+ height: 1px;
+ background: hsl(0, 0%, 80%);
+ }
+ .menu a {
+ cursor: pointer;
+ color: var(--chops-link-color);
+ }
+ .menu a:hover, .menu a:focus {
+ background: var(--chops-active-choice-bg);
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <button class="anchor"
+ @click=${this.toggle}
+ @keydown=${this._exitMenuOnEsc}
+ ?disabled=${this.disabled}
+ title=${this.title || this.label}
+ aria-label=${this.label}
+ aria-expanded=${this.opened}
+ >
+ ${this.text}
+ <i class="material-icons" aria-hidden="true">${this.icon}</i>
+ </button>
+ <div class="menu ${this.menuAlignment}">
+ ${this.items.map((item, index) => this._renderItem(item, index))}
+ <slot></slot>
+ </div>
+ `;
+ }
+
+ /**
+ * Render a single dropdown menu item.
+ * @param {MenuItem} item
+ * @param {number} index The item's position in the list of items.
+ * @return {TemplateResult}
+ */
+ _renderItem(item, index) {
+ if (item.separator) {
+ // The menu item is a no-op divider between sections.
+ return html`
+ <strong ?hidden=${!item.text} class="menu-item">
+ ${item.text}
+ </strong>
+ <hr />
+ `;
+ }
+ if (item.items && item.items.length) {
+ // The menu contains a sub-menu.
+ return html`
+ <mr-dropdown
+ .text=${item.text}
+ .items=${item.items}
+ menuAlignment="side"
+ icon="arrow_right"
+ data-idx=${index}
+ class="menu-item"
+ ></mr-dropdown>
+ `;
+ }
+
+ return html`
+ <a
+ href=${ifDefined(item.url)}
+ @click=${this._runItemHandler}
+ @keydown=${this._onItemKeydown}
+ data-idx=${index}
+ tabindex="0"
+ class="menu-item"
+ >
+ <i
+ class="material-icons"
+ ?hidden=${item.icon === undefined}
+ >${item.icon}</i>
+ ${item.text}
+ </a>
+ `;
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.label = '';
+ this.text = '';
+ this.items = [];
+ this.icon = 'arrow_drop_down';
+ this.menuAlignment = 'right';
+ this.opened = false;
+ this.disabled = false;
+
+ this._boundCloseOnOutsideClick = this._closeOnOutsideClick.bind(this);
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ title: {type: String},
+ label: {type: String},
+ text: {type: String},
+ items: {type: Array},
+ icon: {type: String},
+ menuAlignment: {type: String},
+ opened: {type: Boolean, reflect: true},
+ disabled: {type: Boolean},
+ };
+ }
+
+ /**
+ * Either runs the click handler attached to the clicked item and closes the
+ * menu.
+ * @param {MouseEvent|KeyboardEvent} e
+ */
+ _runItemHandler(e) {
+ if (e instanceof MouseEvent || e.code === 'Enter') {
+ const idx = e.target.dataset.idx;
+ if (idx !== undefined && this.items[idx].handler) {
+ this.items[idx].handler();
+ }
+ this.close();
+ }
+ }
+
+ /**
+ * Runs multiple event handlers when a user types a key while
+ * focusing a menu item.
+ * @param {KeyboardEvent} e
+ */
+ _onItemKeydown(e) {
+ this._runItemHandler(e);
+ this._exitMenuOnEsc(e);
+ }
+
+ /**
+ * If the user types Esc while focusing any dropdown item, then
+ * exit the dropdown.
+ * @param {KeyboardEvent} e
+ */
+ _exitMenuOnEsc(e) {
+ if (e.key === 'Escape') {
+ this.close();
+
+ // Return focus to the anchor of the dropdown on closing, so that
+ // users don't lose their overall focus position within the page.
+ const anchor = this.shadowRoot.querySelector('.anchor');
+ anchor.focus();
+ }
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ window.addEventListener('click', this._boundCloseOnOutsideClick, true);
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener('click', this._boundCloseOnOutsideClick, true);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('label') || changedProperties.has('text')) {
+ if (!this.label && !this.text) {
+ console.error(SCREENREADER_ATTRIBUTE_ERROR);
+ }
+ }
+ }
+
+ /**
+ * Closes and opens the dropdown menu.
+ */
+ toggle() {
+ this.opened = !this.opened;
+ }
+
+ /**
+ * Opens the dropdown menu.
+ */
+ open() {
+ this.opened = true;
+ }
+
+ /**
+ * Closes the dropdown menu.
+ */
+ close() {
+ this.opened = false;
+ }
+
+ /**
+ * Click a specific item in mr-dropdown, using JavaScript. Useful for testing.
+ *
+ * @param {number} i index of the item to click.
+ */
+ clickItem(i) {
+ const items = this.shadowRoot.querySelectorAll('.menu-item');
+ items[i].click();
+ }
+
+ /**
+ * @param {MouseEvent} evt
+ * @private
+ */
+ _closeOnOutsideClick(evt) {
+ if (!this.opened) return;
+
+ const hasMenu = evt.composedPath().find(
+ (node) => {
+ return node === this;
+ },
+ );
+ if (hasMenu) return;
+
+ this.close();
+ }
+}
+
+customElements.define('mr-dropdown', MrDropdown);
diff --git a/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js b/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js
new file mode 100644
index 0000000..51f8ce9
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js
@@ -0,0 +1,276 @@
+// 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 {MrDropdown, SCREENREADER_ATTRIBUTE_ERROR} from './mr-dropdown.js';
+import sinon from 'sinon';
+
+let element;
+let randomButton;
+
+describe('mr-dropdown', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-dropdown');
+ document.body.appendChild(element);
+ element.label = 'new dropdown';
+
+ randomButton = document.createElement('button');
+ document.body.appendChild(randomButton);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ document.body.removeChild(randomButton);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrDropdown);
+ });
+
+ it('warns users about accessibility when no label or text', async () => {
+ element.label = 'ok';
+ sinon.spy(console, 'error');
+
+ await element.updateComplete;
+ sinon.assert.notCalled(console.error);
+
+ element.label = undefined;
+
+ await element.updateComplete;
+ sinon.assert.calledWith(console.error, SCREENREADER_ATTRIBUTE_ERROR);
+
+ console.error.restore();
+ });
+
+ it('toggle changes opened state', () => {
+ element.open();
+ assert.isTrue(element.opened);
+
+ element.close();
+ assert.isFalse(element.opened);
+
+ element.toggle();
+ assert.isTrue(element.opened);
+
+ element.toggle();
+ assert.isFalse(element.opened);
+
+ element.toggle();
+ element.toggle();
+ assert.isFalse(element.opened);
+ });
+
+ it('clicking outside element closes menu', () => {
+ element.open();
+ assert.isTrue(element.opened);
+
+ randomButton.click();
+
+ assert.isFalse(element.opened);
+ });
+
+ it('escape while focusing the anchor closes menu', async () => {
+ element.open();
+ await element.updateComplete;
+
+ assert.isTrue(element.opened);
+
+ const anchor = element.shadowRoot.querySelector('.anchor');
+ anchor.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+
+ assert.isFalse(element.opened);
+ });
+
+ it('other key while focusing the anchor does not close menu', async () => {
+ element.open();
+ await element.updateComplete;
+
+ assert.isTrue(element.opened);
+
+ const anchor = element.shadowRoot.querySelector('.anchor');
+ anchor.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
+
+ assert.isTrue(element.opened);
+ });
+
+ it('escape while focusing an item closes the menu', async () => {
+ element.items = [{text: 'An item'}];
+ element.open();
+ await element.updateComplete;
+
+ assert.isTrue(element.opened);
+
+ const item = element.shadowRoot.querySelector('.menu-item');
+ item.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+
+ assert.isFalse(element.opened);
+ });
+
+ it('icon hidden when undefined', async () => {
+ element.items = [
+ {text: 'test'},
+ ];
+
+ await element.updateComplete;
+
+ const icon = element.shadowRoot.querySelector(
+ '.menu-item > .material-icons');
+
+ assert.isTrue(icon.hidden);
+ });
+
+ it('icon shown when defined, even as empty string', async () => {
+ element.items = [
+ {text: 'test', icon: ''},
+ ];
+
+ await element.updateComplete;
+
+ const icon = element.shadowRoot.querySelector(
+ '.menu-item > .material-icons');
+
+ assert.isFalse(icon.hidden);
+ assert.equal(icon.textContent.trim(), '');
+ });
+
+ it('icon shown when set to material icon', async () => {
+ element.items = [
+ {text: 'test', icon: 'check'},
+ ];
+
+ await element.updateComplete;
+
+ const icon = element.shadowRoot.querySelector(
+ '.menu-item > .material-icons');
+
+ assert.isFalse(icon.hidden);
+ assert.equal(icon.textContent.trim(), 'check');
+ });
+
+ it('items with handlers are handled', async () => {
+ const handler1 = sinon.spy();
+ const handler2 = sinon.spy();
+ const handler3 = sinon.spy();
+
+ element.items = [
+ {
+ url: '#',
+ text: 'blah',
+ handler: handler1,
+ },
+ {
+ url: '#',
+ text: 'rutabaga noop',
+ handler: handler2,
+ },
+ {
+ url: '#',
+ text: 'click me please',
+ handler: handler3,
+ },
+ ];
+
+ element.open();
+
+ await element.updateComplete;
+
+ element.clickItem(0);
+
+ assert.isTrue(handler1.calledOnce);
+ assert.isFalse(handler2.called);
+ assert.isFalse(handler3.called);
+
+ element.clickItem(2);
+
+ assert.isTrue(handler1.calledOnce);
+ assert.isFalse(handler2.called);
+ assert.isTrue(handler3.calledOnce);
+ });
+
+ describe('nested dropdown menus', () => {
+ beforeEach(() => {
+ element.items = [
+ {
+ text: 'test',
+ items: [
+ {text: 'item 1'},
+ {text: 'item 2'},
+ {text: 'item 3'},
+ ],
+ },
+ ];
+
+ element.open();
+ });
+
+ it('nested dropdown menu renders', async () => {
+ await element.updateComplete;
+
+ const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+
+ assert.equal(nestedDropdown.text, 'test');
+ assert.deepEqual(nestedDropdown.items, [
+ {text: 'item 1'},
+ {text: 'item 2'},
+ {text: 'item 3'},
+ ]);
+ });
+
+ it('clicking nested item with handler calls handler', async () => {
+ const handler = sinon.stub();
+ element.items = [{
+ text: 'test',
+ items: [
+ {text: 'item 1'},
+ {
+ text: 'item with handler',
+ handler,
+ },
+ ],
+ }];
+
+ await element.updateComplete;
+
+ const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+
+ nestedDropdown.open();
+ await element.updateComplete;
+
+ // Clicking an unrelated nested item shouldn't call the handler.
+ nestedDropdown.clickItem(0);
+ // Nor should clicking the parent item call the handler.
+ element.clickItem(0);
+ sinon.assert.notCalled(handler);
+
+ element.open();
+ nestedDropdown.open();
+ await element.updateComplete;
+
+ nestedDropdown.clickItem(1);
+ sinon.assert.calledOnce(handler);
+ });
+
+ it('clicking nested dropdown menu toggles nested menu', async () => {
+ await element.updateComplete;
+
+ const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+ const nestedAnchor = nestedDropdown.shadowRoot.querySelector('.anchor');
+
+ assert.isTrue(element.opened);
+ assert.isFalse(nestedDropdown.opened);
+
+ nestedAnchor.click();
+ await element.updateComplete;
+
+ assert.isTrue(element.opened);
+ assert.isTrue(nestedDropdown.opened);
+
+ nestedAnchor.click();
+ await element.updateComplete;
+
+ assert.isTrue(element.opened);
+ assert.isFalse(nestedDropdown.opened);
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-error/mr-error.js b/static_src/elements/framework/mr-error/mr-error.js
new file mode 100644
index 0000000..084a326
--- /dev/null
+++ b/static_src/elements/framework/mr-error/mr-error.js
@@ -0,0 +1,51 @@
+// 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';
+
+
+/**
+ * `<mr-error>`
+ *
+ * A container for showing errors.
+ *
+ */
+export class MrError extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: flex-start;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0.5em 0;
+ padding: 0.25em 8px;
+ border: 1px solid #B71C1C;
+ border-radius: 4px;
+ background: #FFEBEE;
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ i.material-icons {
+ color: #B71C1C;
+ margin-right: 4px;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <i class="material-icons">close</i>
+ <slot></slot>
+ `;
+ }
+}
+
+customElements.define('mr-error', MrError);
diff --git a/static_src/elements/framework/mr-header/mr-header.js b/static_src/elements/framework/mr-header/mr-header.js
new file mode 100644
index 0000000..6603c85
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.js
@@ -0,0 +1,427 @@
+// 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 userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import 'elements/framework/mr-keystrokes/mr-keystrokes.js';
+import '../mr-dropdown/mr-dropdown.js';
+import '../mr-dropdown/mr-account-dropdown.js';
+import './mr-search-bar.js';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * @type {Object<string, string>} JS coding of enum values from
+ * appengine/monorail/api/v3/api_proto/project_objects.proto.
+ */
+const projectRoles = Object.freeze({
+ OWNER: 'Owner',
+ MEMBER: 'Member',
+ CONTRIBUTOR: 'Contributor',
+ NONE: '',
+});
+
+/**
+ * `<mr-header>`
+ *
+ * The header for Monorail.
+ *
+ */
+export class MrHeader extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ color: var(--chops-header-text-color);
+ box-sizing: border-box;
+ background: hsl(221, 67%, 92%);
+ width: 100%;
+ height: var(--monorail-header-height);
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: center;
+ z-index: 800;
+ background-color: var(--chops-primary-header-bg);
+ border-bottom: var(--chops-normal-border);
+ top: 0;
+ position: fixed;
+ padding: 0 4px;
+ font-size: var(--chops-large-font-size);
+ }
+ @media (max-width: 840px) {
+ :host {
+ position: static;
+ }
+ }
+ a {
+ font-size: inherit;
+ color: var(--chops-link-color);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 0 4px;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ a[hidden] {
+ display: none;
+ }
+ a.button {
+ font-size: inherit;
+ height: auto;
+ margin: 0 8px;
+ border: 0;
+ height: 30px;
+ }
+ .home-link {
+ color: var(--chops-gray-900);
+ letter-spacing: 0.5px;
+ font-size: 18px;
+ font-weight: 400;
+ display: flex;
+ font-stretch: 100%;
+ padding-left: 8px;
+ }
+ a.home-link img {
+ /** Cover up default padding with the custom logo. */
+ margin-left: -8px;
+ }
+ a.home-link:hover {
+ text-decoration: none;
+ }
+ mr-search-bar {
+ margin-left: 8px;
+ flex-grow: 2;
+ max-width: 1000px;
+ }
+ i.material-icons {
+ font-size: var(--chops-icon-font-size);
+ color: var(--chops-primary-icon-color);
+ }
+ i.material-icons[hidden] {
+ display: none;
+ }
+ .right-section {
+ font-size: inherit;
+ display: flex;
+ align-items: center;
+ height: 100%;
+ margin-left: auto;
+ justify-content: flex-end;
+ }
+ .hamburger-icon:hover {
+ text-decoration: none;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return this.projectName ?
+ this._renderProjectScope() : this._renderNonProjectScope();
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderProjectScope() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <mr-keystrokes
+ .issueId=${this.queryParams.id}
+ .queryParams=${this.queryParams}
+ .issueEntryUrl=${this.issueEntryUrl}
+ ></mr-keystrokes>
+ <a href="/p/${this.projectName}/issues/list" class="home-link">
+ ${this.projectThumbnailUrl ? html`
+ <img
+ class="project-logo"
+ src=${this.projectThumbnailUrl}
+ title=${this.projectName}
+ />
+ ` : this.projectName}
+ </a>
+ <mr-dropdown
+ class="project-selector"
+ .text=${this.projectName}
+ .items=${this._projectDropdownItems}
+ menuAlignment="left"
+ title=${this.presentationConfig.projectSummary}
+ ></mr-dropdown>
+ <a class="button emphasized new-issue-link" href=${this.issueEntryUrl}>
+ New issue
+ </a>
+ <mr-search-bar
+ .projectName=${this.projectName}
+ .userDisplayName=${this.userDisplayName}
+ .projectSavedQueries=${this.presentationConfig.savedQueries}
+ .initialCan=${this._currentCan}
+ .initialQuery=${this._currentQuery}
+ .queryParams=${this.queryParams}
+ ></mr-search-bar>
+
+ <div class="right-section">
+ <mr-dropdown
+ icon="settings"
+ label="Project Settings"
+ .items=${this._projectSettingsItems}
+ ></mr-dropdown>
+
+ ${this._renderAccount()}
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderNonProjectScope() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <a class="hamburger-icon" title="Main menu" hidden>
+ <i class="material-icons">menu</i>
+ </a>
+ ${this._headerTitle ?
+ html`<span class="home-link">${this._headerTitle}</span>` :
+ html`<a href="/" class="home-link">Monorail</a>`}
+
+ <div class="right-section">
+ ${this._renderAccount()}
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderAccount() {
+ if (!this.userDisplayName) {
+ return html`<a href=${this.loginUrl}>Sign in</a>`;
+ }
+
+ return html`
+ <mr-account-dropdown
+ .userDisplayName=${this.userDisplayName}
+ .logoutUrl=${this.logoutUrl}
+ .loginUrl=${this.loginUrl}
+ ></mr-account-dropdown>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ loginUrl: {type: String},
+ logoutUrl: {type: String},
+ projectName: {type: String},
+ // Project thumbnail is set separately from presentationConfig to prevent
+ // "flashing" logo when navigating EZT pages.
+ projectThumbnailUrl: {type: String},
+ userDisplayName: {type: String},
+ isSiteAdmin: {type: Boolean},
+ userProjects: {type: Object},
+ presentationConfig: {type: Object},
+ queryParams: {type: Object},
+ // TODO(zhangtiff): Change this to be dynamically computed by the
+ // frontend with logic similar to ComputeIssueEntryURL().
+ issueEntryUrl: {type: String},
+ clientLogger: {type: Object},
+ _headerTitle: {type: String},
+ _currentQuery: {type: String},
+ _currentCan: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.presentationConfig = {};
+ this.userProjects = {};
+ this.isSiteAdmin = false;
+
+ this._headerTitle = '';
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.projectName = projectV0.viewedProjectName(state);
+
+ this.userProjects = userV0.projects(state);
+
+ const currentUser = userV0.currentUser(state);
+ this.isSiteAdmin = currentUser ? currentUser.isSiteAdmin : false;
+
+ const presentationConfig = projectV0.viewedPresentationConfig(state);
+ this.presentationConfig = presentationConfig;
+ // Set separately in order allow EZT pages to load project logo before
+ // the GetPresentationConfig pRPC request.
+ this.projectThumbnailUrl = presentationConfig.projectThumbnailUrl;
+
+ this._headerTitle = sitewide.headerTitle(state);
+
+ this._currentQuery = sitewide.currentQuery(state);
+ this._currentCan = sitewide.currentCan(state);
+
+ this.queryParams = sitewide.queryParams(state);
+ }
+
+ /**
+ * @return {boolean} whether the currently logged in user has admin
+ * privileges for the currently viewed project.
+ */
+ get canAdministerProject() {
+ if (!this.userDisplayName) return false; // Not logged in.
+ if (this.isSiteAdmin) return true;
+ if (!this.userProjects || !this.userProjects.ownerOf) return false;
+ return this.userProjects.ownerOf.includes(this.projectName);
+ }
+
+ /**
+ * @return {string} The name of the role the user has in the viewed project.
+ */
+ get roleInCurrentProject() {
+ if (!this.userProjects || !this.projectName) return projectRoles.NONE;
+ const {ownerOf = [], memberOf = [], contributorTo = []} = this.userProjects;
+
+ if (ownerOf.includes(this.projectName)) return projectRoles.OWNER;
+ if (memberOf.includes(this.projectName)) return projectRoles.MEMBER;
+ if (contributorTo.includes(this.projectName)) {
+ return projectRoles.CONTRIBUTOR;
+ }
+
+ return projectRoles.NONE;
+ }
+
+ // TODO(crbug.com/monorail/6891): Remove once we deprecate the old issue
+ // filing wizard.
+ /**
+ * @return {string} A URL for the page the issue filing wizard posts to.
+ */
+ get _wizardPostUrl() {
+ // The issue filing wizard posts to the legacy issue entry page's ".do"
+ // endpoint.
+ return `${this._origin}/p/${this.projectName}/issues/entry.do`;
+ }
+
+ /**
+ * @return {string} The domain name of the current page.
+ */
+ get _origin() {
+ return window.location.origin;
+ }
+
+ /**
+ * Computes the URL the user should see to a file an issue, accounting
+ * for the case where a project has a customIssueEntryUrl to navigate to
+ * the wizard as well.
+ * @return {string} The URL that "New issue" button goes to.
+ */
+ get issueEntryUrl() {
+ const config = this.presentationConfig;
+ const role = this.roleInCurrentProject;
+ const mayBeRedirectedToWizard = role === projectRoles.NONE;
+ if (!this.userDisplayName || !config || !config.customIssueEntryUrl ||
+ !mayBeRedirectedToWizard) {
+ return `/p/${this.projectName}/issues/entry`;
+ }
+
+ const token = prpcClient.token;
+
+ const customUrl = this.presentationConfig.customIssueEntryUrl;
+
+ return `${customUrl}?token=${token}&role=${
+ role}&continue=${this._wizardPostUrl}`;
+ }
+
+ /**
+ * @return {Array<MenuItem>} the dropdown items for the project selector,
+ * showing which projects a user can switch to.
+ */
+ get _projectDropdownItems() {
+ const {userProjects, loginUrl} = this;
+ if (!this.userDisplayName) {
+ return [{text: 'Sign in to see your projects', url: loginUrl}];
+ }
+
+ const items = [];
+ const starredProjects = userProjects.starredProjects || [];
+ const projects = (userProjects.ownerOf || [])
+ .concat(userProjects.memberOf || [])
+ .concat(userProjects.contributorTo || []);
+
+ if (projects.length) {
+ projects.sort();
+ items.push({text: 'My Projects', separator: true});
+
+ projects.forEach((project) => {
+ items.push({text: project, url: `/p/${project}/issues/list`});
+ });
+ }
+
+ if (starredProjects.length) {
+ starredProjects.sort();
+ items.push({text: 'Starred Projects', separator: true});
+
+ starredProjects.forEach((project) => {
+ items.push({text: project, url: `/p/${project}/issues/list`});
+ });
+ }
+
+ if (items.length) {
+ items.push({separator: true});
+ }
+
+ items.push({text: 'All projects', url: '/hosting/'});
+ items.forEach((item) => {
+ item.handler = () => this._projectChangedHandler(item.url);
+ });
+ return items;
+ }
+
+ /**
+ * @return {Array<MenuItem>} dropdown menu items to show in the project
+ * settings menu.
+ */
+ get _projectSettingsItems() {
+ const {projectName, canAdministerProject} = this;
+ const items = [
+ {text: 'People', url: `/p/${projectName}/people/list`},
+ {text: 'Development Process', url: `/p/${projectName}/adminIntro`},
+ {text: 'History', url: `/p/${projectName}/updates/list`},
+ ];
+
+ if (canAdministerProject) {
+ items.push({separator: true});
+ items.push({text: 'Administer', url: `/p/${projectName}/admin`});
+ }
+ return items;
+ }
+
+ /**
+ * Records Google Analytics events for when users change projects using
+ * the selector.
+ * @param {string} url which project URL the user is navigating to.
+ */
+ _projectChangedHandler(url) {
+ // Just log it to GA and continue.
+ logEvent('mr-header', 'project-change', url);
+ }
+}
+
+customElements.define('mr-header', MrHeader);
diff --git a/static_src/elements/framework/mr-header/mr-header.test.js b/static_src/elements/framework/mr-header/mr-header.test.js
new file mode 100644
index 0000000..277347f
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.test.js
@@ -0,0 +1,191 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+import {MrHeader} from './mr-header.js';
+
+
+window.CS_env = {
+ token: 'foo-token',
+};
+
+let element;
+
+describe('mr-header', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-header');
+ document.body.appendChild(element);
+
+ window.ga = sinon.stub();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrHeader);
+ });
+
+ it('presentationConfig renders', async () => {
+ element.projectName = 'best-project';
+ element.projectThumbnailUrl = 'http://images.google.com/';
+ element.presentationConfig = {
+ projectSummary: 'The best project',
+ };
+
+ await element.updateComplete;
+
+ assert.equal(element.shadowRoot.querySelector('.project-logo').src,
+ 'http://images.google.com/');
+
+ assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+ '/p/best-project/issues/entry');
+
+ assert.equal(element.shadowRoot.querySelector('.project-selector').title,
+ 'The best project');
+ });
+
+ describe('issueEntryUrl', () => {
+ let oldToken;
+
+ beforeEach(() => {
+ oldToken = prpcClient.token;
+ prpcClient.token = 'token1';
+
+ element.projectName = 'proj';
+
+ sinon.stub(element, '_origin').get(() => 'http://localhost');
+ });
+
+ afterEach(() => {
+ prpcClient.token = oldToken;
+ });
+
+ it('updates on project change', async () => {
+ await element.updateComplete;
+
+ assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+ '/p/proj/issues/entry');
+
+ element.projectName = 'the-best-project';
+
+ await element.updateComplete;
+
+ assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+ '/p/the-best-project/issues/entry');
+ });
+
+ it('generates wizard URL when customIssueEntryUrl defined', () => {
+ element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+ element.userProjects = {ownerOf: ['not-proj']};
+ element.userDisplayName = 'test@example.com';
+ assert.equal(element.issueEntryUrl,
+ 'https://issue.wizard?token=token1&role=&' +
+ 'continue=http://localhost/p/proj/issues/entry.do');
+ });
+
+ it('uses default issue filing URL when user is not logged in', () => {
+ element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+ element.userDisplayName = '';
+ assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+ });
+
+ it('uses default issue filing URL when user is project owner', () => {
+ element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+ element.userProjects = {ownerOf: ['proj']};
+ assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+ });
+
+ it('uses default issue filing URL when user is project member', () => {
+ element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+ element.userProjects = {memberOf: ['proj']};
+ assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+ });
+
+ it('uses default issue filing URL when user is project contributor', () => {
+ element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+ element.userProjects = {contributorTo: ['proj']};
+ assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+ });
+ });
+
+
+ it('canAdministerProject is false when user is not logged in', () => {
+ element.userDisplayName = '';
+
+ assert.isFalse(element.canAdministerProject);
+ });
+
+ it('canAdministerProject is true when user is site admin', () => {
+ element.userDisplayName = 'test@example.com';
+ element.isSiteAdmin = true;
+
+ assert.isTrue(element.canAdministerProject);
+
+ element.isSiteAdmin = false;
+
+ assert.isFalse(element.canAdministerProject);
+ });
+
+ it('canAdministerProject is true when user is owner', () => {
+ element.userDisplayName = 'test@example.com';
+ element.isSiteAdmin = false;
+
+ element.projectName = 'chromium';
+ element.userProjects = {ownerOf: ['chromium']};
+
+ assert.isTrue(element.canAdministerProject);
+
+ element.projectName = 'v8';
+
+ assert.isFalse(element.canAdministerProject);
+
+ element.userProjects = {memberOf: ['v8']};
+
+ assert.isFalse(element.canAdministerProject);
+ });
+
+ it('_projectDropdownItems tells user to sign in if not logged in', () => {
+ element.userDisplayName = '';
+ element.loginUrl = 'http://login';
+
+ const items = element._projectDropdownItems;
+
+ // My Projects
+ assert.deepEqual(items[0], {
+ text: 'Sign in to see your projects',
+ url: 'http://login',
+ });
+ });
+
+ it('_projectDropdownItems computes projects for user', () => {
+ element.userProjects = {
+ ownerOf: ['chromium'],
+ memberOf: ['v8'],
+ contributorTo: ['skia'],
+ starredProjects: ['gerrit'],
+ };
+ element.userDisplayName = 'test@example.com';
+
+ const items = element._projectDropdownItems;
+
+ // TODO(http://crbug.com/monorail/6236): Replace these checks with
+ // deepInclude once we upgrade Chai.
+ // My Projects
+ assert.equal(items[1].text, 'chromium');
+ assert.equal(items[1].url, '/p/chromium/issues/list');
+ assert.equal(items[2].text, 'skia');
+ assert.equal(items[2].url, '/p/skia/issues/list');
+ assert.equal(items[3].text, 'v8');
+ assert.equal(items[3].url, '/p/v8/issues/list');
+
+ // Starred Projects
+ assert.equal(items[5].text, 'gerrit');
+ assert.equal(items[5].url, '/p/gerrit/issues/list');
+ });
+});
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.js b/static_src/elements/framework/mr-header/mr-search-bar.js
new file mode 100644
index 0000000..536dfcf
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.js
@@ -0,0 +1,501 @@
+// 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 page from 'page';
+import qs from 'qs';
+
+import '../mr-dropdown/mr-dropdown.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import ClientLogger from 'monitoring/client-logger';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+
+// Search field input regex testing for all digits
+// indicating that the user wants to jump to the specified issue.
+const JUMP_RE = /^\d+$/;
+
+/**
+ * `<mr-search-bar>`
+ *
+ * The searchbar for Monorail.
+ *
+ */
+export class MrSearchBar extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ --mr-search-bar-background: var(--chops-white);
+ --mr-search-bar-border-radius: 4px;
+ --mr-search-bar-border: var(--chops-normal-border);
+ --mr-search-bar-chip-color: var(--chops-gray-200);
+ height: 30px;
+ font-size: var(--chops-large-font-size);
+ }
+ input#searchq {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ flex-grow: 2;
+ min-width: 100px;
+ border: none;
+ border-top: var(--mr-search-bar-border);
+ border-bottom: var(--mr-search-bar-border);
+ background: var(--mr-search-bar-background);
+ height: 100%;
+ box-sizing: border-box;
+ padding: 0 2px;
+ font-size: inherit;
+ }
+ mr-dropdown {
+ text-align: right;
+ display: flex;
+ text-overflow: ellipsis;
+ box-sizing: border-box;
+ background: var(--mr-search-bar-background);
+ border: var(--mr-search-bar-border);
+ border-left: 0;
+ border-radius: 0 var(--mr-search-bar-border-radius)
+ var(--mr-search-bar-border-radius) 0;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ }
+ button {
+ font-size: inherit;
+ order: -1;
+ background: var(--mr-search-bar-background);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ box-sizing: border-box;
+ border: var(--mr-search-bar-border);
+ border-left: none;
+ border-right: none;
+ padding: 0 8px;
+ }
+ form {
+ display: flex;
+ height: 100%;
+ width: 100%;
+ align-items: center;
+ justify-content: flex-start;
+ flex-direction: row;
+ }
+ i.material-icons {
+ font-size: var(--chops-icon-font-size);
+ color: var(--chops-primary-icon-color);
+ }
+ .select-container {
+ order: -2;
+ max-width: 150px;
+ min-width: 50px;
+ flex-shrink: 1;
+ height: 100%;
+ position: relative;
+ box-sizing: border-box;
+ border: var(--mr-search-bar-border);
+ border-radius: var(--mr-search-bar-border-radius) 0 0
+ var(--mr-search-bar-border-radius);
+ background: var(--mr-search-bar-chip-color);
+ }
+ .select-container i.material-icons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ right: 0;
+ top: 0;
+ height: 100%;
+ width: 20px;
+ z-index: 2;
+ padding: 0;
+ }
+ select {
+ color: var(--chops-primary-font-color);
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ width: 100%;
+ height: 100%;
+ background: none;
+ margin: 0;
+ padding: 0 20px 0 8px;
+ box-sizing: border-box;
+ border: 0;
+ z-index: 3;
+ font-size: inherit;
+ position: relative;
+ }
+ select::-ms-expand {
+ display: none;
+ }
+ select::after {
+ position: relative;
+ right: 0;
+ content: 'arrow_drop_down';
+ font-family: 'Material Icons';
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <form
+ @submit=${this._submitSearch}
+ @keypress=${this._submitSearchWithKeypress}
+ >
+ ${this._renderSearchScopeSelector()}
+ <input
+ id="searchq"
+ type="text"
+ name="q"
+ placeholder="Search ${this.projectName} issues..."
+ .value=${this.initialQuery || ''}
+ autocomplete="off"
+ aria-label="Search box"
+ @focus=${this._searchEditStarted}
+ @blur=${this._searchEditFinished}
+ spellcheck="false"
+ />
+ <button type="submit">
+ <i class="material-icons">search</i>
+ </button>
+ <mr-dropdown
+ label="Search options"
+ .items=${this._searchMenuItems}
+ ></mr-dropdown>
+ </form>
+ `;
+ }
+
+ /**
+ * Render helper for the select menu that lets user select which search
+ * context/saved query they want to use.
+ * @return {TemplateResult}
+ */
+ _renderSearchScopeSelector() {
+ return html`
+ <div class="select-container">
+ <i class="material-icons" role="presentation">arrow_drop_down</i>
+ <select
+ id="can"
+ name="can"
+ @change=${this._redirectOnSelect}
+ aria-label="Search scope"
+ >
+ <optgroup label="Search within">
+ <option
+ value="1"
+ ?selected=${this.initialCan === '1'}
+ >All issues</option>
+ <option
+ value="2"
+ ?selected=${this.initialCan === '2'}
+ >Open issues</option>
+ <option
+ value="3"
+ ?selected=${this.initialCan === '3'}
+ >Open and owned by me</option>
+ <option
+ value="4"
+ ?selected=${this.initialCan === '4'}
+ >Open and reported by me</option>
+ <option
+ value="5"
+ ?selected=${this.initialCan === '5'}
+ >Open and starred by me</option>
+ <option
+ value="8"
+ ?selected=${this.initialCan === '8'}
+ >Open with comment by me</option>
+ <option
+ value="6"
+ ?selected=${this.initialCan === '6'}
+ >New issues</option>
+ <option
+ value="7"
+ ?selected=${this.initialCan === '7'}
+ >Issues to verify</option>
+ </optgroup>
+ <optgroup label="Project queries" ?hidden=${!this.userDisplayName}>
+ ${this._renderSavedQueryOptions(this.projectSavedQueries, 'project-query')}
+ <option data-href="/p/${this.projectName}/adminViews">
+ Manage project queries...
+ </option>
+ </optgroup>
+ <optgroup label="My saved queries" ?hidden=${!this.userDisplayName}>
+ ${this._renderSavedQueryOptions(this.userSavedQueries, 'user-query')}
+ <option data-href="/u/${this.userDisplayName}/queries">
+ Manage my saved queries...
+ </option>
+ </optgroup>
+ </select>
+ </div>
+ `;
+ }
+
+ /**
+ * Render helper for adding saved queries to the search scope select.
+ * @param {Array<SavedQuery>} queries Queries to render.
+ * @param {string} className CSS class to be applied to each option.
+ * @return {Array<TemplateResult>}
+ */
+ _renderSavedQueryOptions(queries, className) {
+ if (!queries) return;
+ return queries.map((query) => html`
+ <option
+ class=${className}
+ value=${query.queryId}
+ ?selected=${this.initialCan === query.queryId}
+ >${query.name}</option>
+ `);
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ projectName: {type: String},
+ userDisplayName: {type: String},
+ initialCan: {type: String},
+ initialQuery: {type: String},
+ projectSavedQueries: {type: Array},
+ userSavedQueries: {type: Array},
+ queryParams: {type: Object},
+ keptQueryParams: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.queryParams = {};
+ this.keptQueryParams = [
+ 'sort',
+ 'groupby',
+ 'colspec',
+ 'x',
+ 'y',
+ 'mode',
+ 'cells',
+ 'num',
+ ];
+ this.initialQuery = '';
+ this.initialCan = '2';
+ this.projectSavedQueries = [];
+ this.userSavedQueries = [];
+
+ this.clientLogger = new ClientLogger('issues');
+
+ this._page = page;
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ // Global event listeners. Make sure to unbind these when the
+ // element disconnects.
+ this._boundFocus = this.focus.bind(this);
+ window.addEventListener('focus-search', this._boundFocus);
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ window.removeEventListener('focus-search', this._boundFocus);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (this.userDisplayName && changedProperties.has('userDisplayName')) {
+ const userSavedQueriesPromise = prpcClient.call('monorail.Users',
+ 'GetSavedQueries', {});
+ userSavedQueriesPromise.then((resp) => {
+ this.userSavedQueries = resp.savedQueries;
+ });
+ }
+ }
+
+ /**
+ * Sends an event to ClientLogger describing that the user started typing
+ * a search query.
+ */
+ _searchEditStarted() {
+ this.clientLogger.logStart('query-edit', 'user-time');
+ this.clientLogger.logStart('issue-search', 'user-time');
+ }
+
+ /**
+ * Sends an event to ClientLogger saying that the user finished typing a
+ * search.
+ */
+ _searchEditFinished() {
+ this.clientLogger.logEnd('query-edit');
+ }
+
+ /**
+ * On Shift+Enter, this handler opens the search in a new tab.
+ * @param {KeyboardEvent} e
+ */
+ _submitSearchWithKeypress(e) {
+ if (e.key === 'Enter' && (e.shiftKey)) {
+ const form = e.currentTarget;
+ this._runSearch(form, true);
+ }
+ // In all other cases, we want to let the submit handler do the work.
+ // ie: pressing 'Enter' on a form should natively open it in a new tab.
+ }
+
+ /**
+ * Update the URL on form submit.
+ * @param {Event} e
+ */
+ _submitSearch(e) {
+ e.preventDefault();
+
+ const form = e.target;
+ this._runSearch(form);
+ }
+
+ /**
+ * Updates the URL with the new search set in the query string.
+ * @param {HTMLFormElement} form the native form element to submit.
+ * @param {boolean=} newTab whether to open the search in a new tab.
+ */
+ _runSearch(form, newTab) {
+ this.clientLogger.logEnd('query-edit');
+ this.clientLogger.logPause('issue-search', 'user-time');
+ this.clientLogger.logStart('issue-search', 'computer-time');
+
+ const params = {};
+
+ this.keptQueryParams.forEach((param) => {
+ if (param in this.queryParams) {
+ params[param] = this.queryParams[param];
+ }
+ });
+
+ params.q = form.q.value.trim();
+ params.can = form.can.value;
+
+ this._navigateToNext(params, newTab);
+ }
+
+ /**
+ * Attempt to jump-to-issue, otherwise continue to list view
+ * @param {Object} params URL navigation parameters
+ * @param {boolean} newTab
+ */
+ async _navigateToNext(params, newTab = false) {
+ let resp;
+ if (JUMP_RE.test(params.q)) {
+ const message = {
+ issueRef: {
+ projectName: this.projectName,
+ localId: params.q,
+ },
+ };
+
+ try {
+ resp = await prpcClient.call(
+ 'monorail.Issues', 'GetIssue', message,
+ );
+ } catch (error) {
+ // Fall through to navigateToList
+ }
+ }
+ if (resp && resp.issue) {
+ const link = issueRefToUrl(resp.issue, params);
+ this._page(link);
+ } else {
+ this._navigateToList(params, newTab);
+ }
+ }
+
+ /**
+ * Navigate to list view, currently splits on old and new view
+ * @param {Object} params URL navigation parameters
+ * @param {boolean} newTab
+ * @fires Event#refreshList
+ * @private
+ */
+ _navigateToList(params, newTab = false) {
+ const pathname = `/p/${this.projectName}/issues/list`;
+
+ const hasChanges = !window.location.pathname.startsWith(pathname) ||
+ this.queryParams.q !== params.q ||
+ this.queryParams.can !== params.can;
+
+ const url =`${pathname}?${qs.stringify(params)}`;
+
+ if (newTab) {
+ window.open(url, '_blank', 'noopener');
+ } else if (hasChanges) {
+ this._page(url);
+ } else {
+ // TODO(zhangtiff): Replace this event with Redux once all of Monorail
+ // uses Redux.
+ // This is needed because navigating to the exact same page does not
+ // cause a URL change to happen.
+ this.dispatchEvent(new Event('refreshList',
+ {'composed': true, 'bubbles': true}));
+ }
+ }
+
+ /**
+ * Wrap the native focus() function for the search form to allow parent
+ * elements to focus the search.
+ */
+ focus() {
+ const search = this.shadowRoot.querySelector('#searchq');
+ search.focus();
+ }
+
+ /**
+ * Populates the search dropdown.
+ * @return {Array<MenuItem>}
+ */
+ get _searchMenuItems() {
+ const projectName = this.projectName;
+ return [
+ {
+ text: 'Advanced search',
+ url: `/p/${projectName}/issues/advsearch`,
+ },
+ {
+ text: 'Search tips',
+ url: `/p/${projectName}/issues/searchtips`,
+ },
+ ];
+ }
+
+ /**
+ * The search dropdown includes links like "Manage my saved queries..."
+ * that automatically navigate a user to a new page when they select those
+ * options.
+ * @param {Event} evt
+ */
+ _redirectOnSelect(evt) {
+ const target = evt.target;
+ const option = target.options[target.selectedIndex];
+
+ if (option.dataset.href) {
+ this._page(option.dataset.href);
+ }
+ }
+}
+
+customElements.define('mr-search-bar', MrSearchBar);
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.test.js b/static_src/elements/framework/mr-header/mr-search-bar.test.js
new file mode 100644
index 0000000..c758a41
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.test.js
@@ -0,0 +1,244 @@
+// 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 {MrSearchBar} from './mr-search-bar.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+import {clientLoggerFake} from 'shared/test/fakes.js';
+
+
+window.CS_env = {
+ token: 'foo-token',
+};
+
+let element;
+
+describe('mr-search-bar', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-search-bar');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrSearchBar);
+ });
+
+ it('render user saved queries', async () => {
+ element.userDisplayName = 'test@user.com';
+ element.userSavedQueries = [
+ {name: 'test query', queryId: 101},
+ {name: 'hello world', queryId: 202},
+ ];
+
+ await element.updateComplete;
+
+ const queryOptions = element.shadowRoot.querySelectorAll(
+ '.user-query');
+
+ assert.equal(queryOptions.length, 2);
+
+ assert.equal(queryOptions[0].value, '101');
+ assert.equal(queryOptions[0].textContent, 'test query');
+
+ assert.equal(queryOptions[1].value, '202');
+ assert.equal(queryOptions[1].textContent, 'hello world');
+ });
+
+ it('render project saved queries', async () => {
+ element.userDisplayName = 'test@user.com';
+ element.projectSavedQueries = [
+ {name: 'test query', queryId: 101},
+ {name: 'hello world', queryId: 202},
+ ];
+
+ await element.updateComplete;
+
+ const queryOptions = element.shadowRoot.querySelectorAll(
+ '.project-query');
+
+ assert.equal(queryOptions.length, 2);
+
+ assert.equal(queryOptions[0].value, '101');
+ assert.equal(queryOptions[0].textContent, 'test query');
+
+ assert.equal(queryOptions[1].value, '202');
+ assert.equal(queryOptions[1].textContent, 'hello world');
+ });
+
+ it('search input resets form value when initialQuery changes', async () => {
+ element.initialQuery = 'first query';
+ await element.updateComplete;
+
+ const queryInput = element.shadowRoot.querySelector('#searchq');
+
+ assert.equal(queryInput.value, 'first query');
+
+ // Simulate a user typing something into the search form.
+ queryInput.value = 'blah';
+
+ element.initialQuery = 'second query';
+ await element.updateComplete;
+
+ // 'blah' disappears because the new initialQuery causes the form to
+ // reset.
+ assert.equal(queryInput.value, 'second query');
+ });
+
+ it('unrelated property changes do not reset query form', async () => {
+ element.initialQuery = 'first query';
+ await element.updateComplete;
+
+ const queryInput = element.shadowRoot.querySelector('#searchq');
+
+ assert.equal(queryInput.value, 'first query');
+
+ // Simulate a user typing something into the search form.
+ queryInput.value = 'blah';
+
+ element.initialCan = '5';
+ await element.updateComplete;
+
+ assert.equal(queryInput.value, 'blah');
+ });
+
+ it('spell check is off for search bar', async () => {
+ await element.updateComplete;
+ const searchElement = element.shadowRoot.querySelector('#searchq');
+ assert.equal(searchElement.getAttribute('spellcheck'), 'false');
+ });
+
+ describe('search form submit', () => {
+ let prpcClientStub;
+ beforeEach(() => {
+ element.clientLogger = clientLoggerFake();
+
+ element._page = sinon.stub();
+ sinon.stub(window, 'open');
+
+ element.projectName = 'chromium';
+ prpcClientStub = sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ window.open.restore();
+ prpcClient.call.restore();
+ });
+
+ it('prevents default', async () => {
+ await element.updateComplete;
+
+ const form = element.shadowRoot.querySelector('form');
+
+ // Note: HTMLFormElement's submit function does not run submit handlers
+ // but clicking a submit buttons programmatically works.
+ const event = new Event('submit');
+ sinon.stub(event, 'preventDefault');
+ form.dispatchEvent(event);
+
+ sinon.assert.calledOnce(event.preventDefault);
+ });
+
+ it('uses initial values when no form changes', async () => {
+ element.initialQuery = 'test query';
+ element.initialCan = '3';
+
+ await element.updateComplete;
+
+ const form = element.shadowRoot.querySelector('form');
+
+ form.dispatchEvent(new Event('submit'));
+
+ sinon.assert.calledOnce(element._page);
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?q=test%20query&can=3');
+ });
+
+ it('adds form values to url', async () => {
+ await element.updateComplete;
+
+ const form = element.shadowRoot.querySelector('form');
+
+ form.q.value = 'test';
+ form.can.value = '1';
+
+ form.dispatchEvent(new Event('submit'));
+
+ sinon.assert.calledOnce(element._page);
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?q=test&can=1');
+ });
+
+ it('trims query', async () => {
+ await element.updateComplete;
+
+ const form = element.shadowRoot.querySelector('form');
+
+ form.q.value = ' abc ';
+ form.can.value = '1';
+
+ form.dispatchEvent(new Event('submit'));
+
+ sinon.assert.calledOnce(element._page);
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?q=abc&can=1');
+ });
+
+ it('jumps to issue for digit-only query', async () => {
+ prpcClientStub.returns(Promise.resolve({issue: 'hello world'}));
+
+ await element.updateComplete;
+
+ const form = element.shadowRoot.querySelector('form');
+
+ form.q.value = '123';
+ form.can.value = '1';
+
+ form.dispatchEvent(new Event('submit'));
+
+ await element._navigateToNext;
+
+ const expected = issueRefToUrl('hello world', {q: '123', can: '1'});
+ sinon.assert.calledWith(element._page, expected);
+ });
+
+ it('only keeps kept query params', async () => {
+ element.queryParams = {fakeParam: 'test', x: 'Status'};
+ element.keptParams = ['x'];
+
+ await element.updateComplete;
+
+ const form = element.shadowRoot.querySelector('form');
+
+ form.dispatchEvent(new Event('submit'));
+
+ sinon.assert.calledOnce(element._page);
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?x=Status&q=&can=2');
+ });
+
+ it('on shift+enter opens search in new tab', async () => {
+ await element.updateComplete;
+
+ const form = element.shadowRoot.querySelector('form');
+
+ form.q.value = 'test';
+ form.can.value = '1';
+
+ // Dispatch event from an input in the form.
+ form.q.dispatchEvent(new KeyboardEvent('keypress',
+ {key: 'Enter', shiftKey: true, bubbles: true}));
+
+ sinon.assert.calledOnce(window.open);
+ sinon.assert.calledWith(window.open,
+ '/p/chromium/issues/list?q=test&can=1', '_blank', 'noopener');
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js
new file mode 100644
index 0000000..13f8267
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js
@@ -0,0 +1,62 @@
+// 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.
+
+/** @const {string} CSV download link's data href prefix, RFC 4810 Section 3 */
+export const CSV_DATA_HREF_PREFIX = 'data:text/csv;charset=utf-8,';
+
+/**
+ * Format array into plaintext csv
+ * @param {Array<Array>} data
+ * @return {string}
+ */
+export const convertListContentToCsv = (data) => {
+ const result = data.reduce((acc, row) => {
+ return `${acc}\r\n${row.map(preventCSVInjectionAndStringify).join(',')}`;
+ }, '');
+ // Remove leading /r and /n
+ return result.slice(2);
+};
+
+/**
+ * Prevent CSV injection, escape double quotes, and wrap with double quotes
+ * See owasp.org/index.php/CSV_Injection
+ * @param {string} cell
+ * @return {string}
+ */
+export const preventCSVInjectionAndStringify = (cell) => {
+ // Prepend all double quotes with another double quote, RFC 4810 Section 2.7
+ let escaped = cell.replace(/"/g, '""');
+
+ // prevent CSV injection: owasp.org/index.php/CSV_Injection
+ if (cell[0] === '=' ||
+ cell[0] === '+' ||
+ cell[0] === '-' ||
+ cell[0] === '@') {
+ escaped = `'${escaped}`;
+ }
+
+ // Wrap cell with double quotes, RFC 4810 Section 2.7
+ return `"${escaped}"`;
+};
+
+/**
+ * Prepare data for csv download by converting array of array into csv string
+ * @param {Array<Array<string>>} data
+ * @param {Array<string>=} headers Column headers
+ * @return {string} CSV formatted string
+ */
+export const prepareDataForDownload = (data, headers = []) => {
+ const mainContent = [headers, ...data];
+
+ return `${convertListContentToCsv(mainContent)}`;
+};
+
+/**
+ * Constructs download link url from csv string data.
+ * @param {string} data CSV data
+ * @return {string}
+ */
+export const constructHref = (data = '') => {
+ return `${CSV_DATA_HREF_PREFIX}${encodeURIComponent(data)}`;
+};
diff --git a/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js
new file mode 100644
index 0000000..cd124a5
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js
@@ -0,0 +1,145 @@
+// 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 {
+ constructHref,
+ convertListContentToCsv,
+ prepareDataForDownload,
+ preventCSVInjectionAndStringify,
+} from './list-to-csv-helpers.js';
+
+describe('constructHref', () => {
+ it('has default of empty string', () => {
+ const result = constructHref();
+ assert.equal(result, 'data:text/csv;charset=utf-8,');
+ });
+
+ it('starts with data:', () => {
+ const result = constructHref('');
+ assert.isTrue(result.startsWith('data:'));
+ });
+
+ it('uses charset=utf-8', () => {
+ const result = constructHref('');
+ assert.isTrue(result.search('charset=utf-8') > -1);
+ });
+
+ it('encodes URI component', () => {
+ const encodeFuncStub = sinon.stub(window, 'encodeURIComponent');
+ constructHref('');
+ sinon.assert.calledOnce(encodeFuncStub);
+
+ window.encodeURIComponent.restore();
+ });
+
+ it('encodes URI component', () => {
+ const input = 'foo, bar fizz=buzz';
+ const expected = 'foo%2C%20bar%20fizz%3Dbuzz';
+ const output = constructHref(input);
+
+ assert.equal(expected, output.split(',')[1]);
+ });
+});
+
+describe('convertListContentToCsv', () => {
+ it('joins rows with carriage return and line feed, CRLF', () => {
+ const input = [['foobar'], ['fizzbuzz']];
+ const expected = '"foobar"\r\n"fizzbuzz"';
+ assert.equal(expected, convertListContentToCsv(input));
+ });
+
+ it('joins columns with commas', () => {
+ const input = [['foo', 'bar', 'fizz', 'buzz']];
+ const expected = '"foo","bar","fizz","buzz"';
+ assert.equal(expected, convertListContentToCsv(input));
+ });
+
+ it('starts with non-empty row', () => {
+ const input = [['foobar']];
+ const expected = '"foobar"';
+ const result = convertListContentToCsv(input);
+ assert.equal(expected, result);
+ assert.isFalse(result.startsWith('\r\n'));
+ });
+});
+
+describe('prepareDataForDownload', () => {
+ it('prepends header row', () => {
+ const headers = ['column1', 'column2'];
+ const result = prepareDataForDownload([['a', 'b']], headers);
+
+ const expected = `"column1","column2"`;
+ assert.equal(expected, result.split('\r\n')[0]);
+ assert.isTrue(result.startsWith(expected));
+ });
+});
+
+describe('preventCSVInjectionAndStringify', () => {
+ it('prepends all double quotes with another double quote', () => {
+ let input = '"hello world"';
+ let expect = '""hello world""';
+ assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+
+ input = 'Just a double quote: " ';
+ expect = 'Just a double quote: "" ';
+ assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+
+ input = 'Multiple"double"quotes"""';
+ expect = 'Multiple""double""quotes""""""';
+ assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+ });
+
+ it('wraps string with double quotes', () => {
+ let input = '"hello world"';
+ let expected = preventCSVInjectionAndStringify(input);
+ assert.equal('"', expected[0]);
+ assert.equal('"', expected[expected.length-1]);
+
+ input = 'For unevent quotes too: " ';
+ expected = '"For unevent quotes too: "" "';
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+ input = 'And for ending quotes"""';
+ expected = '"And for ending quotes"""""""';
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+ });
+
+ it('wraps strings containing commas with double quotes', () => {
+ const input = 'Let\'s, add, a bunch, of, commas,';
+ const expected = '"Let\'s, add, a bunch, of, commas,"';
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+ });
+
+ it('can handle strings containing commas and new line chars', () => {
+ const input = `""new"",\r\nline "" "",\r\nand 'end', and end`;
+ const expected = `"""""new"""",\r\nline """" """",\r\nand 'end', and end"`;
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+ });
+
+ it('preserves single quotes', () => {
+ let input = `all the 'single' quotes`;
+ let expected = `"all the 'single' quotes"`;
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+ input = `''''' fives single quotes before and after '''''`;
+ expected = `"''''' fives single quotes before and after '''''"`;
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+ });
+
+ it('prevents csv injection', () => {
+ let input = `@@Should prepend with single quote`;
+ let expected = `"'@@Should prepend with single quote"`;
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+ input = `at symbol @ later on, do not expect ' at start`;
+ expected = `"at symbol @ later on, do not expect ' at start"`;
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+ input = `==@+=--@Should prepend with single quote`;
+ expected = `"'==@+=--@Should prepend with single quote"`;
+ assert.equal(expected, preventCSVInjectionAndStringify(input));
+ });
+});
diff --git a/static_src/elements/framework/mr-issue-list/mr-issue-list.js b/static_src/elements/framework/mr-issue-list/mr-issue-list.js
new file mode 100644
index 0000000..3e0a279
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-issue-list.js
@@ -0,0 +1,1575 @@
+// 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 page from 'page';
+import {connectStore, store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import {constructHref, prepareDataForDownload} from './list-to-csv-helpers.js';
+import {
+ issueRefToUrl,
+ issueRefToString,
+ issueStringToRef,
+ issueToIssueRef,
+ issueToIssueRefString,
+ labelRefsToOneWordLabels,
+} from 'shared/convertersV0.js';
+import {isTextInput, findDeepEventTarget} from 'shared/dom-helpers.js';
+import {
+ urlWithNewParams,
+ pluralize,
+ setHasAny,
+ objectValuesForKeys,
+} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {parseColSpec, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import './mr-show-columns-dropdown.js';
+
+/**
+ * Column to display name mapping dictionary
+ * @type {Object<string, string>}
+ */
+const COLUMN_DISPLAY_NAMES = Object.freeze({
+ 'summary': 'Summary + Labels',
+});
+
+/** @const {number} Button property value of DOM click event */
+const PRIMARY_BUTTON = 0;
+/** @const {number} Button property value of DOM auxclick event */
+const MIDDLE_BUTTON = 1;
+
+/** @const {string} A short transition to ease movement of list items. */
+const EASE_OUT_TRANSITION = 'transform 0.05s cubic-bezier(0, 0, 0.2, 1)';
+
+/**
+ * Really high cardinality attributes like ID and Summary are unlikely to be
+ * useful if grouped, so it's better to just hide the option.
+ * @const {Set<string>}
+ */
+const UNGROUPABLE_COLUMNS = new Set(['id', 'summary']);
+
+/**
+ * Columns that should render as issue links.
+ * @const {Set<string>}
+ */
+const ISSUE_COLUMNS = new Set(['id', 'mergedinto', 'blockedon', 'blocking']);
+
+/**
+ * `<mr-issue-list>`
+ *
+ * A list of issues intended to be used in multiple contexts.
+ * @extends {LitElement}
+ */
+export class MrIssueList extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ width: 100%;
+ font-size: var(--chops-main-font-size);
+ }
+ table {
+ width: 100%;
+ }
+ .edit-widget-container {
+ display: flex;
+ flex-wrap: no-wrap;
+ align-items: center;
+ }
+ mr-issue-star {
+ --mr-star-size: 18px;
+ margin-bottom: 1px;
+ margin-left: 4px;
+ }
+ input[type="checkbox"] {
+ cursor: pointer;
+ margin: 0 4px;
+ width: 16px;
+ height: 16px;
+ border-radius: 2px;
+ box-sizing: border-box;
+ appearance: none;
+ -webkit-appearance: none;
+ border: 2px solid var(--chops-gray-400);
+ position: relative;
+ background: var(--chops-white);
+ }
+ th input[type="checkbox"] {
+ border-color: var(--chops-gray-500);
+ }
+ input[type="checkbox"]:checked {
+ background: var(--chops-primary-accent-color);
+ border-color: var(--chops-primary-accent-color);
+ }
+ input[type="checkbox"]:checked::after {
+ left: 1px;
+ top: 2px;
+ position: absolute;
+ content: "";
+ width: 8px;
+ height: 4px;
+ border: 2px solid white;
+ border-right: none;
+ border-top: none;
+ transform: rotate(-45deg);
+ }
+ td, th.group-header {
+ padding: 4px 8px;
+ text-overflow: ellipsis;
+ border-bottom: var(--chops-normal-border);
+ cursor: pointer;
+ font-weight: normal;
+ }
+ .group-header-content {
+ height: 100%;
+ width: 100%;
+ align-items: center;
+ display: flex;
+ }
+ th.group-header i.material-icons {
+ font-size: var(--chops-icon-font-size);
+ color: var(--chops-primary-icon-color);
+ margin-right: 4px;
+ }
+ td.ignore-navigation {
+ cursor: default;
+ }
+ th {
+ background: var(--chops-table-header-bg);
+ white-space: nowrap;
+ text-align: left;
+ border-bottom: var(--chops-normal-border);
+ }
+ th.selection-header {
+ padding: 3px 8px;
+ }
+ th > mr-dropdown, th > mr-show-columns-dropdown {
+ font-weight: normal;
+ color: var(--chops-link-color);
+ --mr-dropdown-icon-color: var(--chops-link-color);
+ --mr-dropdown-anchor-padding: 3px 8px;
+ --mr-dropdown-anchor-font-weight: bold;
+ --mr-dropdown-menu-min-width: 150px;
+ }
+ tr {
+ padding: 0 8px;
+ }
+ tr[selected] {
+ background: var(--chops-selected-bg);
+ }
+ td:first-child, th:first-child {
+ border-left: 4px solid transparent;
+ }
+ tr[cursored] > td:first-child {
+ border-left: 4px solid var(--chops-blue-700);
+ }
+ mr-crbug-link {
+ /* We need the shortlink to be hidden but still accessible.
+ * The opacity attribute visually hides a link while still
+ * keeping it in the DOM.opacity. */
+ --mr-crbug-link-opacity: 0;
+ --mr-crbug-link-opacity-focused: 1;
+ }
+ td:hover > mr-crbug-link {
+ --mr-crbug-link-opacity: 1;
+ }
+ .col-summary, .header-summary {
+ /* Setting a table cell to 100% width makes it take up
+ * all remaining space in the table, not the full width of
+ * the table. */
+ width: 100%;
+ }
+ .summary-label {
+ display: inline-block;
+ margin: 0 2px;
+ color: var(--chops-green-800);
+ text-decoration: none;
+ font-size: 90%;
+ }
+ .summary-label:hover {
+ text-decoration: underline;
+ }
+ td.draggable i {
+ opacity: 0;
+ }
+ td.draggable {
+ color: var(--chops-primary-icon-color);
+ cursor: grab;
+ padding-left: 0;
+ padding-right: 0;
+ }
+ tr.dragged {
+ opacity: 0.74;
+ }
+ tr:hover td.draggable i {
+ opacity: 1;
+ }
+ .csv-download-container {
+ border-bottom: none;
+ text-align: end;
+ cursor: default;
+ }
+ #hidden-data-link {
+ display: none;
+ }
+ @media (min-width: 1024px) {
+ .first-row th {
+ position: sticky;
+ top: var(--monorail-header-height);
+ z-index: 10;
+ }
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ const selectAllChecked = this._selectedIssues.size > 0;
+ const checkboxLabel = `Select ${selectAllChecked ? 'None' : 'All'}`;
+
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <table cellspacing="0">
+ <thead>
+ <tr class="first-row">
+ ${this.rerank ? html`<th></th>` : ''}
+ <th class="selection-header">
+ <div class="edit-widget-container">
+ ${this.selectionEnabled ? html`
+ <input
+ class="select-all"
+ .checked=${selectAllChecked}
+ type="checkbox"
+ aria-label=${checkboxLabel}
+ title=${checkboxLabel}
+ @change=${this._selectAll}
+ />
+ ` : ''}
+ </div>
+ </th>
+ ${this.columns.map((column, i) => this._renderHeader(column, i))}
+ <th style="z-index: ${this.highestZIndex};">
+ <mr-show-columns-dropdown
+ title="Show columns"
+ menuAlignment="right"
+ .columns=${this.columns}
+ .issues=${this.issues}
+ .defaultFields=${this.defaultFields}
+ ></mr-show-columns-dropdown>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ ${this._renderIssues()}
+ </tbody>
+ ${this.userDisplayName && html`
+ <tfoot><tr><td colspan=999 class="csv-download-container">
+ <a id="download-link" aria-label="Download page as CSV"
+ @click=${this._downloadCsv} href>CSV</a>
+ <a id="hidden-data-link" download="${this.projectName}-issues.csv"
+ href=${this._csvDataHref}></a>
+ </td></tr></tfoot>
+ `}
+ </table>
+ `;
+ }
+
+ /**
+ * @param {string} column
+ * @param {number} i The index of the column in the table.
+ * @return {TemplateResult} html for header for the i-th column.
+ * @private
+ */
+ _renderHeader(column, i) {
+ // zIndex is used to render the z-index property in descending order
+ const zIndex = this.highestZIndex - i;
+ const colKey = column.toLowerCase();
+ const name = colKey in COLUMN_DISPLAY_NAMES ? COLUMN_DISPLAY_NAMES[colKey] :
+ column;
+ return html`
+ <th style="z-index: ${zIndex};" class="header-${colKey}">
+ <mr-dropdown
+ class="dropdown-${colKey}"
+ .text=${name}
+ .items=${this._headerActions(column, i)}
+ menuAlignment="left"
+ ></mr-dropdown>
+ </th>`;
+ }
+
+ /**
+ * @param {string} column
+ * @param {number} i The index of the column in the table.
+ * @return {Array<Object>} Available actions for the column.
+ * @private
+ */
+ _headerActions(column, i) {
+ const columnKey = column.toLowerCase();
+
+ const isGroupable = this.sortingAndGroupingEnabled &&
+ !UNGROUPABLE_COLUMNS.has(columnKey);
+
+ let showOnly = [];
+ if (isGroupable) {
+ const values = [...this._uniqueValuesByColumn.get(columnKey)];
+ if (values.length) {
+ showOnly = [{
+ text: 'Show only',
+ items: values.map((v) => ({
+ text: v,
+ handler: () => this.showOnly(column, v),
+ })),
+ }];
+ }
+ }
+ const sortingActions = this.sortingAndGroupingEnabled ? [
+ {
+ text: 'Sort up',
+ handler: () => this.updateSortSpec(column),
+ },
+ {
+ text: 'Sort down',
+ handler: () => this.updateSortSpec(column, true),
+ },
+ ] : [];
+ const actions = [
+ ...sortingActions,
+ ...showOnly,
+ {
+ text: 'Hide column',
+ handler: () => this.removeColumn(i),
+ },
+ ];
+ if (isGroupable) {
+ actions.push({
+ text: 'Group rows',
+ handler: () => this.addGroupBy(i),
+ });
+ }
+ return actions;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderIssues() {
+ // Keep track of all the groups that we've seen so far to create
+ // group headers as needed.
+ const {issues, groupedIssues} = this;
+
+ if (groupedIssues) {
+ // Make sure issues in groups are rendered with unique indices across
+ // groups to make sure hot keys and the like still work.
+ let indexOffset = 0;
+ return html`${groupedIssues.map(({groupName, issues}) => {
+ const template = html`
+ ${this._renderGroup(groupName, issues, indexOffset)}
+ `;
+ indexOffset += issues.length;
+ return template;
+ })}`;
+ }
+
+ return html`
+ ${issues.map((issue, i) => this._renderRow(issue, i))}
+ `;
+ }
+
+ /**
+ * @param {string} groupName
+ * @param {Array<Issue>} issues
+ * @param {number} iOffset
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderGroup(groupName, issues, iOffset) {
+ if (!this.groups.length) return html``;
+
+ const count = issues.length;
+ const groupKey = groupName.toLowerCase();
+ const isHidden = this._hiddenGroups.has(groupKey);
+
+ return html`
+ <tr>
+ <th
+ class="group-header"
+ colspan="${this.numColumns}"
+ @click=${() => this._toggleGroup(groupKey)}
+ aria-expanded=${(!isHidden).toString()}
+ >
+ <div class="group-header-content">
+ <i
+ class="material-icons"
+ title=${isHidden ? 'Show' : 'Hide'}
+ >${isHidden ? 'add' : 'remove'}</i>
+ ${count} ${pluralize(count, 'issue')}: ${groupName}
+ </div>
+ </th>
+ </tr>
+ ${issues.map((issue, i) => this._renderRow(issue, iOffset + i, isHidden))}
+ `;
+ }
+
+ /**
+ * @param {string} groupKey Lowercase group key.
+ * @private
+ */
+ _toggleGroup(groupKey) {
+ if (this._hiddenGroups.has(groupKey)) {
+ this._hiddenGroups.delete(groupKey);
+ } else {
+ this._hiddenGroups.add(groupKey);
+ }
+
+ // Lit-element's default hasChanged check does not notice when Sets mutate.
+ this.requestUpdate('_hiddenGroups');
+ }
+
+ /**
+ * @param {Issue} issue
+ * @param {number} i Index within the list of issues
+ * @param {boolean=} isHidden
+ * @return {TemplateResult}
+ */
+ _renderRow(issue, i, isHidden = false) {
+ const rowSelected = this._selectedIssues.has(issueRefToString(issue));
+ const id = issueRefToString(issue);
+ const cursorId = issueRefToString(this.cursor);
+ const hasCursor = cursorId === id;
+ const dragged = this._dragging && rowSelected;
+
+ return html`
+ <tr
+ class="row-${i} list-row ${dragged ? 'dragged' : ''}"
+ ?selected=${rowSelected}
+ ?cursored=${hasCursor}
+ ?hidden=${isHidden}
+ data-issue-ref=${id}
+ data-index=${i}
+ data-name=${issue.name}
+ @focus=${this._setRowAsCursorOnFocus}
+ @click=${this._clickIssueRow}
+ @auxclick=${this._clickIssueRow}
+ @keydown=${this._keydownIssueRow}
+ tabindex="0"
+ >
+ ${this.rerank ? html`
+ <td class="draggable ignore-navigation"
+ @mousedown=${this._onMouseDown}>
+ <i class="material-icons" title="Drag issue">drag_indicator</i>
+ </td>
+ ` : ''}
+ <td class="ignore-navigation">
+ <div class="edit-widget-container">
+ ${this.selectionEnabled ? html`
+ <input
+ class="issue-checkbox"
+ .value=${id}
+ .checked=${rowSelected}
+ type="checkbox"
+ data-index=${i}
+ aria-label="Select Issue ${issue.localId}"
+ @change=${this._selectIssue}
+ @click=${this._selectIssueRange}
+ />
+ ` : ''}
+ ${this.starringEnabled ? html`
+ <mr-issue-star
+ .issueRef=${issueToIssueRef(issue)}
+ ></mr-issue-star>
+ ` : ''}
+ </div>
+ </td>
+
+ ${this.columns.map((column) => html`
+ <td class="col-${column.toLowerCase()}">
+ ${this._renderCell(column, issue)}
+ </td>
+ `)}
+
+ <td>
+ <mr-crbug-link .issue=${issue}></mr-crbug-link>
+ </td>
+ </tr>
+ `;
+ }
+
+ /**
+ * @param {string} column
+ * @param {Issue} issue
+ * @return {TemplateResult} Html for the given column for the given issue.
+ * @private
+ */
+ _renderCell(column, issue) {
+ const columnName = column.toLowerCase();
+ if (columnName === 'summary') {
+ return html`
+ ${issue.summary}
+ ${labelRefsToOneWordLabels(issue.labelRefs).map(({label}) => html`
+ <a
+ class="summary-label"
+ href="/p/${issue.projectName}/issues/list?q=label%3A${label}"
+ >${label}</a>
+ `)}
+ `;
+ }
+ const values = this.extractFieldValues(issue, column);
+
+ if (!values.length) return EMPTY_FIELD_VALUE;
+
+ // TODO(zhangtiff): Make this based on the "ISSUE" field type rather than a
+ // hardcoded list of issue fields.
+ if (ISSUE_COLUMNS.has(columnName)) {
+ return values.map((issueRefString, i) => {
+ const issue = this._issueForRefString(issueRefString, this.projectName);
+ return html`
+ <mr-issue-link
+ .projectName=${this.projectName}
+ .issue=${issue}
+ .queryParams=${this._queryParams}
+ short
+ ></mr-issue-link>${values.length - 1 > i ? ', ' : ''}
+ `;
+ });
+ }
+ return values.join(', ');
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Array of columns to display.
+ */
+ columns: {type: Array},
+ /**
+ * Array of built in fields that are available outside of project
+ * configuration.
+ */
+ defaultFields: {type: Array},
+ /**
+ * A function that takes in an issue and a field name and returns the
+ * value for that field in the issue. This function accepts custom fields,
+ * built in fields, and ad hoc fields computed from label prefixes.
+ */
+ extractFieldValues: {type: Object},
+ /**
+ * Array of columns that are used as groups for issues.
+ */
+ groups: {type: Array},
+ /**
+ * List of issues to display.
+ */
+ issues: {type: Array},
+ /**
+ * A Redux action creator that calls the API to rerank the issues
+ * in the list. If set, reranking is enabled for this issue list.
+ */
+ rerank: {type: Object},
+ /**
+ * Whether issues should be selectable or not.
+ */
+ selectionEnabled: {type: Boolean},
+ /**
+ * Whether issues should be sortable and groupable or not. This will
+ * change how column headers will be displayed. The ability to sort and
+ * group are currently coupled.
+ */
+ sortingAndGroupingEnabled: {type: Boolean},
+ /**
+ * Whether to show issue starring or not.
+ */
+ starringEnabled: {type: Boolean},
+ /**
+ * A query representing the current set of matching issues in the issue
+ * list. Does not necessarily match queryParams.q since queryParams.q can
+ * be empty while currentQuery is set to a default project query.
+ */
+ currentQuery: {type: String},
+ /**
+ * Object containing URL parameters to be preserved when issue links are
+ * clicked. This Object is only used for the purpose of preserving query
+ * parameters across links, not for the purpose of evaluating the query
+ * parameters themselves to get values like columns, sort, or q. This
+ * separation is important because we don't want to tightly couple this
+ * list component with a specific URL system.
+ * @private
+ */
+ _queryParams: {type: Object},
+ /**
+ * The initial cursor that a list view uses. This attribute allows users
+ * of the list component to specify and control the cursor. When the
+ * initialCursor attribute updates, the list focuses the element specified
+ * by the cursor.
+ */
+ initialCursor: {type: String},
+ /**
+ * Logged in user's display name
+ */
+ userDisplayName: {type: String},
+ /**
+ * IssueRef Object specifying which issue the user is currently focusing.
+ */
+ _localCursor: {type: Object},
+ /**
+ * Set of group keys that are currently hidden.
+ */
+ _hiddenGroups: {type: Object},
+ /**
+ * Set of all selected issues where each entry is an issue ref string.
+ */
+ _selectedIssues: {type: Object},
+ /**
+ * List of unique phase names for all phases in issues.
+ */
+ _phaseNames: {type: Array},
+ /**
+ * True iff the user is dragging issues.
+ */
+ _dragging: {type: Boolean},
+ /**
+ * CSV data in data HREF format, used to download csv
+ */
+ _csvDataHref: {type: String},
+ /**
+ * Function to get a full Issue object for a given ref string.
+ */
+ _issueForRefString: {type: Object},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+ /** @type {Array<Issue>} */
+ this.issues = [];
+ // TODO(jojwang): monorail:6336#c8, when ezt listissues page is fully
+ // deprecated, remove phaseNames from mr-issue-list.
+ this._phaseNames = [];
+ /** @type {IssueRef} */
+ this._localCursor;
+ /** @type {IssueRefString} */
+ this.initialCursor;
+ /** @type {Set<IssueRefString>} */
+ this._selectedIssues = new Set();
+ /** @type {string} */
+ this.projectName;
+ /** @type {Object} */
+ this._queryParams = {};
+ /** @type {string} */
+ this.currentQuery = '';
+ /**
+ * @param {Array<String>} items
+ * @param {number} index
+ * @return {Promise<void>}
+ */
+ this.rerank = null;
+ /** @type {boolean} */
+ this.selectionEnabled = false;
+ /** @type {boolean} */
+ this.sortingAndGroupingEnabled = false;
+ /** @type {boolean} */
+ this.starringEnabled = false;
+ /** @type {Array} */
+ this.columns = ['ID', 'Summary'];
+ /** @type {Array<string>} */
+ this.defaultFields = [];
+ /** @type {Array} */
+ this.groups = [];
+ this.userDisplayName = '';
+
+ /** @type {function(KeyboardEvent): void} */
+ this._boundRunListHotKeys = this._runListHotKeys.bind(this);
+ /** @type {function(MouseEvent): void} */
+ this._boundOnMouseMove = this._onMouseMove.bind(this);
+ /** @type {function(MouseEvent): void} */
+ this._boundOnMouseUp = this._onMouseUp.bind(this);
+
+ /**
+ * @param {Issue} _issue
+ * @param {string} _fieldName
+ * @return {Array<string>}
+ */
+ this.extractFieldValues = (_issue, _fieldName) => [];
+
+ /**
+ * @param {IssueRefString} _issueRefString
+ * @param {string} projectName The currently viewed project.
+ * @return {Issue}
+ */
+ this._issueForRefString = (_issueRefString, projectName) =>
+ issueStringToRef(_issueRefString, projectName);
+
+ this._hiddenGroups = new Set();
+
+ this._starredIssues = new Set();
+ this._fetchingStarredIssues = false;
+ this._starringIssues = new Map();
+
+ this._uniqueValuesByColumn = new Map();
+
+ this._dragging = false;
+ this._mouseX = null;
+ this._mouseY = null;
+
+ /** @type {number} */
+ this._lastSelectedCheckbox = -1;
+
+ // Expose page.js for stubbing.
+ this._page = page;
+ /** @type {string} page data in csv format as data href */
+ this._csvDataHref = '';
+ };
+
+ /** @override */
+ stateChanged(state) {
+ this._starredIssues = issueV0.starredIssues(state);
+ this._fetchingStarredIssues =
+ issueV0.requests(state).fetchStarredIssues.requesting;
+ this._starringIssues = issueV0.starringIssues(state);
+
+ this._phaseNames = (issueV0.issueListPhaseNames(state) || []);
+ this._queryParams = sitewide.queryParams(state);
+
+ this._issueForRefString = issueV0.issueForRefString(state);
+ }
+
+ /** @override */
+ firstUpdated() {
+ // Only attach an event listener once the DOM has rendered.
+ window.addEventListener('keydown', this._boundRunListHotKeys);
+ this._dataLink = this.shadowRoot.querySelector('#hidden-data-link');
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ window.removeEventListener('keydown', this._boundRunListHotKeys);
+ }
+
+ /**
+ * @override
+ * @fires CustomEvent#selectionChange
+ */
+ update(changedProperties) {
+ if (changedProperties.has('issues')) {
+ // Clear selected issues to avoid an ever-growing Set size. In the future,
+ // we may want to consider saving selections across issue reloads, though,
+ // such as in the case or list refreshing.
+ this._selectedIssues = new Set();
+ this.dispatchEvent(new CustomEvent('selectionChange'));
+
+ // Clear group toggle state when the list of issues changes to prevent an
+ // ever-growing Set size.
+ this._hiddenGroups = new Set();
+
+ this._lastSelectedCheckbox = -1;
+ }
+
+ const valuesByColumnArgs = ['issues', 'columns', 'extractFieldValues'];
+ if (setHasAny(changedProperties, valuesByColumnArgs)) {
+ this._uniqueValuesByColumn = this._computeUniqueValuesByColumn(
+ ...objectValuesForKeys(this, valuesByColumnArgs));
+ }
+
+ super.update(changedProperties);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('initialCursor')) {
+ const ref = issueStringToRef(this.initialCursor, this.projectName);
+ const row = this._getRowFromIssueRef(ref);
+ if (row) {
+ row.focus();
+ }
+ }
+ }
+
+ /**
+ * Iterates through all issues in a list to sort unique values
+ * across columns, for use in the "Show only" feature.
+ * @param {Array} issues
+ * @param {Array} columns
+ * @param {function(Issue, string): Array<string>} fieldExtractor
+ * @return {Map} Map where each entry has a String key for the
+ * lowercase column name and a Set value, continuing all values for
+ * that column.
+ */
+ _computeUniqueValuesByColumn(issues, columns, fieldExtractor) {
+ const valueMap = new Map(
+ columns.map((col) => [col.toLowerCase(), new Set()]));
+
+ issues.forEach((issue) => {
+ columns.forEach((col) => {
+ const key = col.toLowerCase();
+ const valueSet = valueMap.get(key);
+
+ const values = fieldExtractor(issue, col);
+ // Note: This allows multiple casings of the same values to be added
+ // to the Set.
+ values.forEach((v) => valueSet.add(v));
+ });
+ });
+ return valueMap;
+ }
+
+ /**
+ * Used for dynamically computing z-index to ensure column dropdowns overlap
+ * properly.
+ */
+ get highestZIndex() {
+ return this.columns.length + 10;
+ }
+
+ /**
+ * The number of columns displayed in the table. This is the count of
+ * customized columns + number of built in columns.
+ */
+ get numColumns() {
+ return this.columns.length + 2;
+ }
+
+ /**
+ * Sort issues into groups if groups are defined. The grouping feature is used
+ * when the "groupby" URL parameter is set in the list view.
+ */
+ get groupedIssues() {
+ if (!this.groups || !this.groups.length) return;
+
+ const issuesByGroup = new Map();
+
+ this.issues.forEach((issue) => {
+ const groupName = this._groupNameForIssue(issue);
+ const groupKey = groupName.toLowerCase();
+
+ if (!issuesByGroup.has(groupKey)) {
+ issuesByGroup.set(groupKey, {groupName, issues: [issue]});
+ } else {
+ const entry = issuesByGroup.get(groupKey);
+ entry.issues.push(issue);
+ }
+ });
+ return [...issuesByGroup.values()];
+ }
+
+ /**
+ * The currently selected issue, with _localCursor overriding initialCursor.
+ *
+ * @return {IssueRef} The currently selected issue.
+ */
+ get cursor() {
+ if (this._localCursor) {
+ return this._localCursor;
+ }
+ if (this.initialCursor) {
+ return issueStringToRef(this.initialCursor, this.projectName);
+ }
+ return {};
+ }
+
+ /**
+ * Computes the name of the group that an issue belongs to. Issues are grouped
+ * by fields that the user specifies and group names are generated using a
+ * combination of an issue's field values for all specified groups.
+ *
+ * @param {Issue} issue
+ * @return {string}
+ */
+ _groupNameForIssue(issue) {
+ const groups = this.groups;
+ const keyPieces = [];
+
+ groups.forEach((group) => {
+ const values = this.extractFieldValues(issue, group);
+ if (!values.length) {
+ keyPieces.push(`-has:${group}`);
+ } else {
+ values.forEach((v) => {
+ keyPieces.push(`${group}=${v}`);
+ });
+ }
+ });
+
+ return keyPieces.join(' ');
+ }
+
+ /**
+ * @return {Array<Issue>} Selected issues in the order they appear.
+ */
+ get selectedIssues() {
+ return this.issues.filter((issue) =>
+ this._selectedIssues.has(issueToIssueRefString(issue)));
+ }
+
+ /**
+ * Update the search query to filter values matching a specific one.
+ *
+ * @param {string} column name of the column being filtered.
+ * @param {string} value value of the field to filter by.
+ */
+ showOnly(column, value) {
+ column = column.toLowerCase();
+
+ // TODO(zhangtiff): Handle edge cases where column names are not
+ // mapped directly to field names. For example, "AllLabels", should
+ // query for "Labels".
+ const querySegment = `${column}=${value}`;
+
+ let query = this.currentQuery.trim();
+
+ if (!query.includes(querySegment)) {
+ query += ' ' + querySegment;
+
+ this._updateQueryParams({q: query.trim()}, ['start']);
+ }
+ }
+
+ /**
+ * Update sort parameter in the URL based on user input.
+ *
+ * @param {string} column name of the column to be sorted.
+ * @param {boolean} descending descending or ascending order.
+ */
+ updateSortSpec(column, descending = false) {
+ column = column.toLowerCase();
+ const oldSpec = this._queryParams.sort || '';
+ const columns = parseColSpec(oldSpec.toLowerCase());
+
+ // Remove any old instances of the same sort spec.
+ const newSpec = columns.filter(
+ (c) => c && c !== column && c !== `-${column}`);
+
+ newSpec.unshift(`${descending ? '-' : ''}${column}`);
+
+ this._updateQueryParams({sort: newSpec.join(' ')}, ['start']);
+ }
+
+ /**
+ * Updates the groupby URL parameter to include a new column to group.
+ *
+ * @param {number} i index of the column to be grouped.
+ */
+ addGroupBy(i) {
+ const groups = [...this.groups];
+ const columns = [...this.columns];
+ const groupedColumn = columns[i];
+ columns.splice(i, 1);
+
+ groups.unshift(groupedColumn);
+
+ this._updateQueryParams({
+ groupby: groups.join(' '),
+ colspec: columns.join('+'),
+ }, ['start']);
+ }
+
+ /**
+ * Removes the column at a particular index.
+ *
+ * @param {number} i the issue column to be removed.
+ */
+ removeColumn(i) {
+ const columns = [...this.columns];
+ columns.splice(i, 1);
+ this.reloadColspec(columns);
+ }
+
+ /**
+ * Adds a new column to a particular index.
+ *
+ * @param {string} name of the new column added.
+ */
+ addColumn(name) {
+ this.reloadColspec([...this.columns, name]);
+ }
+
+ /**
+ * Reflects changes to the columns of an issue list to the URL, through
+ * frontend routing.
+ *
+ * @param {Array} newColumns the new colspec to set in the URL.
+ */
+ reloadColspec(newColumns) {
+ this._updateQueryParams({colspec: newColumns.join('+')});
+ }
+
+ /**
+ * Navigates to the same URL as the current page, but with query
+ * params updated.
+ *
+ * @param {Object} newParams keys and values of the queryParams
+ * Object to be updated.
+ * @param {Array} deletedParams keys to be cleared from queryParams.
+ */
+ _updateQueryParams(newParams = {}, deletedParams = []) {
+ const url = urlWithNewParams(this._baseUrl(), this._queryParams, newParams,
+ deletedParams);
+ this._page(url);
+ }
+
+ /**
+ * Get the current URL of the page, without query params. Useful for
+ * test stubbing.
+ *
+ * @return {string} the URL of the list page, without params.
+ */
+ _baseUrl() {
+ return window.location.pathname;
+ }
+
+ /**
+ * Run issue list hot keys. This event handler needs to be bound globally
+ * because a list cursor can be defined even when no element in the list is
+ * focused.
+ * @param {KeyboardEvent} e
+ */
+ _runListHotKeys(e) {
+ if (!this.issues || !this.issues.length) return;
+ const target = findDeepEventTarget(e);
+ if (!target || isTextInput(target)) return;
+
+ const key = e.key;
+
+ const activeRow = this._getCursorElement();
+
+ let i = -1;
+ if (activeRow) {
+ i = Number.parseInt(activeRow.dataset.index);
+
+ const issue = this.issues[i];
+
+ switch (key) {
+ case 's': // Star focused issue.
+ this._starIssue(issueToIssueRef(issue));
+ return;
+ case 'x': // Toggle selection of focused issue.
+ const issueRefString = issueToIssueRefString(issue);
+ this._updateSelectedIssues([issueRefString],
+ !this._selectedIssues.has(issueRefString));
+ return;
+ case 'o': // Open current issue.
+ case 'O': // Open current issue in new tab.
+ this._navigateToIssue(issue, e.shiftKey);
+ return;
+ }
+ }
+
+ // Move up and down the issue list.
+ // 'j' moves 'down'.
+ // 'k' moves 'up'.
+ if (key === 'j' || key === 'k') {
+ if (key === 'j') { // Navigate down the list.
+ i += 1;
+ if (i >= this.issues.length) {
+ i = 0;
+ }
+ } else if (key === 'k') { // Navigate up the list.
+ i -= 1;
+ if (i < 0) {
+ i = this.issues.length - 1;
+ }
+ }
+
+ const nextRow = this.shadowRoot.querySelector(`.row-${i}`);
+ this._setRowAsCursor(nextRow);
+ }
+ }
+
+ /**
+ * @return {HTMLTableRowElement}
+ */
+ _getCursorElement() {
+ const cursor = this.cursor;
+ if (cursor) {
+ // If there's a cursor set, use that instead of focus.
+ return this._getRowFromIssueRef(cursor);
+ }
+ return;
+ }
+
+ /**
+ * @param {FocusEvent} e
+ */
+ _setRowAsCursorOnFocus(e) {
+ this._setRowAsCursor(/** @type {HTMLTableRowElement} */ (e.target));
+ }
+
+ /**
+ *
+ * @param {HTMLTableRowElement} row
+ */
+ _setRowAsCursor(row) {
+ this._localCursor = issueStringToRef(row.dataset.issueRef,
+ this.projectName);
+ row.focus();
+ }
+
+ /**
+ * @param {IssueRef} ref The issueRef to query for.
+ * @return {HTMLTableRowElement}
+ */
+ _getRowFromIssueRef(ref) {
+ return this.shadowRoot.querySelector(
+ `.list-row[data-issue-ref="${issueRefToString(ref)}"]`);
+ }
+
+ /**
+ * Returns an Array containing every <tr> in the list, excluding the header.
+ * @return {Array<HTMLTableRowElement>}
+ */
+ _getRows() {
+ return Array.from(this.shadowRoot.querySelectorAll('.list-row'));
+ }
+
+ /**
+ * Returns an Array containing every selected <tr> in the list.
+ * @return {Array<HTMLTableRowElement>}
+ */
+ _getSelectedRows() {
+ return this._getRows().filter((row) => {
+ return this._selectedIssues.has(row.dataset.issueRef);
+ });
+ }
+
+ /**
+ * @param {IssueRef} issueRef Issue to star
+ */
+ _starIssue(issueRef) {
+ if (!this.starringEnabled) return;
+ const issueKey = issueRefToString(issueRef);
+
+ // TODO(zhangtiff): Find way to share star disabling logic more.
+ const isStarring = this._starringIssues.has(issueKey) &&
+ this._starringIssues.get(issueKey).requesting;
+ const starEnabled = !this._fetchingStarredIssues && !isStarring;
+ if (starEnabled) {
+ const newIsStarred = !this._starredIssues.has(issueKey);
+ this._starIssueInternal(issueRef, newIsStarred);
+ }
+ }
+
+ /**
+ * Wrap store.dispatch and issue.star, for testing.
+ *
+ * @param {IssueRef} issueRef the issue being starred.
+ * @param {boolean} newIsStarred whether to star or unstar the issue.
+ * @private
+ */
+ _starIssueInternal(issueRef, newIsStarred) {
+ store.dispatch(issueV0.star(issueRef, newIsStarred));
+ }
+ /**
+ * @param {Event} e
+ * @fires CustomEvent#open-dialog
+ * @private
+ */
+ _selectAll(e) {
+ const checkbox = /** @type {HTMLInputElement} */ (e.target);
+
+ if (checkbox.checked) {
+ this._selectedIssues = new Set(this.issues.map(issueRefToString));
+ } else {
+ this._selectedIssues = new Set();
+ }
+ this.dispatchEvent(new CustomEvent('selectionChange'));
+ }
+
+ // TODO(zhangtiff): Implement Shift+Click to select a range of checkboxes
+ // for the 'x' hot key.
+ /**
+ * @param {MouseEvent} e
+ * @private
+ */
+ _selectIssueRange(e) {
+ if (!this.selectionEnabled) return;
+
+ const checkbox = /** @type {HTMLInputElement} */ (e.target);
+
+ const index = Number.parseInt(checkbox.dataset.index);
+ if (Number.isNaN(index)) {
+ console.error('Issue checkbox has invalid data-index attribute.');
+ return;
+ }
+
+ const lastIndex = this._lastSelectedCheckbox;
+ if (e.shiftKey && lastIndex >= 0) {
+ const newCheckedState = checkbox.checked;
+
+ const start = Math.min(lastIndex, index);
+ const end = Math.max(lastIndex, index) + 1;
+
+ const updatedIssueKeys = this.issues.slice(start, end).map(
+ issueToIssueRefString);
+ this._updateSelectedIssues(updatedIssueKeys, newCheckedState);
+ }
+
+ this._lastSelectedCheckbox = index;
+ }
+
+ /**
+ * @param {Event} e
+ * @private
+ */
+ _selectIssue(e) {
+ if (!this.selectionEnabled) return;
+
+ const checkbox = /** @type {HTMLInputElement} */ (e.target);
+ const issueKey = checkbox.value;
+
+ this._updateSelectedIssues([issueKey], checkbox.checked);
+ }
+
+ /**
+ * @param {Array<IssueRefString>} issueKeys Stringified issue refs.
+ * @param {boolean} selected
+ * @fires CustomEvent#selectionChange
+ * @private
+ */
+ _updateSelectedIssues(issueKeys, selected) {
+ let hasChanges = false;
+
+ issueKeys.forEach((issueKey) => {
+ const oldSelection = this._selectedIssues.has(issueKey);
+
+ if (selected) {
+ this._selectedIssues.add(issueKey);
+ } else if (this._selectedIssues.has(issueKey)) {
+ this._selectedIssues.delete(issueKey);
+ }
+
+ const newSelection = this._selectedIssues.has(issueKey);
+
+ hasChanges = hasChanges || newSelection !== oldSelection;
+ });
+
+
+ if (hasChanges) {
+ this.requestUpdate('_selectedIssues');
+ this.dispatchEvent(new CustomEvent('selectionChange'));
+ }
+ }
+
+ /**
+ * Handles 'Enter' being pressed when a row is focused.
+ * Note we install the 'Enter' listener on the row rather than the window so
+ * 'Enter' behaves as expected when the focus is on other elements.
+ *
+ * @param {KeyboardEvent} e
+ * @private
+ */
+ _keydownIssueRow(e) {
+ if (e.key === 'Enter') {
+ this._maybeOpenIssueRow(e);
+ }
+ }
+
+ /**
+ * Handles mouseDown to start drag events.
+ * @param {MouseEvent} event
+ * @private
+ */
+ _onMouseDown(event) {
+ event.cancelable && event.preventDefault();
+
+ this._mouseX = event.clientX;
+ this._mouseY = event.clientY;
+
+ this._setRowAsCursor(event.currentTarget.parentNode);
+ this._startDrag();
+
+ // We add the event listeners to window because the mouse can go out of the
+ // bounds of the target element. window.mouseUp still triggers even if the
+ // mouse is outside the browser window.
+ window.addEventListener('mousemove', this._boundOnMouseMove);
+ window.addEventListener('mouseup', this._boundOnMouseUp);
+ }
+
+ /**
+ * Handles mouseMove to continue drag events.
+ * @param {MouseEvent} event
+ * @private
+ */
+ _onMouseMove(event) {
+ event.cancelable && event.preventDefault();
+
+ const x = event.clientX - this._mouseX;
+ const y = event.clientY - this._mouseY;
+ this._continueDrag(x, y);
+ }
+
+ /**
+ * Handles mouseUp to end drag events.
+ * @param {MouseEvent} event
+ * @private
+ */
+ _onMouseUp(event) {
+ event.cancelable && event.preventDefault();
+
+ window.removeEventListener('mousemove', this._boundOnMouseMove);
+ window.removeEventListener('mouseup', this._boundOnMouseUp);
+
+ this._endDrag(event.clientY - this._mouseY);
+ }
+
+ /**
+ * Gives a visual indicator that we've started dragging an issue row.
+ * @private
+ */
+ _startDrag() {
+ this._dragging = true;
+
+ // If the dragged row is not selected, select it.
+ // TODO(dtu): Allow dragging an existing selection for multi-drag.
+ const issueRefString = issueRefToString(this.cursor);
+ this._selectedIssues = new Set();
+ this._updateSelectedIssues([issueRefString], true);
+ }
+
+ /**
+ * @param {number} x The x-distance the cursor has moved since mouseDown.
+ * @param {number} y The y-distance the cursor has moved since mouseDown.
+ * @private
+ */
+ _continueDrag(x, y) {
+ // Unselected rows: Transition them to their new positions.
+ const [rows, initialIndex, finalIndex] = this._computeRerank(y);
+ this._translateRows(rows, initialIndex, finalIndex);
+
+ // Selected rows: Stick them to the cursor. No transition.
+ for (const row of this._getSelectedRows()) {
+ row.style.transform = `translate(${x}px, ${y}px`;
+ };
+ }
+
+ /**
+ * @param {number} y The y-distance the cursor has moved since mouseDown.
+ * @private
+ */
+ async _endDrag(y) {
+ this._dragging = false;
+
+ // Unselected rows: Transition them to their new positions.
+ const [rows, initialIndex, finalIndex] = this._computeRerank(y);
+ const targetTranslation =
+ this._translateRows(rows, initialIndex, finalIndex);
+
+ // Selected rows: Transition them to their final positions
+ // and reset their opacity.
+ const selectedRows = this._getSelectedRows();
+ for (const row of selectedRows) {
+ row.style.transition = EASE_OUT_TRANSITION;
+ row.style.transform = `translate(0px, ${targetTranslation}px)`;
+ };
+
+ // Submit the change.
+ const items = selectedRows.map((row) => row.dataset.name);
+ await this.rerank(items, finalIndex);
+
+ // Reset the transforms.
+ for (const row of this._getRows()) {
+ row.style.transition = '';
+ row.style.transform = '';
+ };
+
+ // Set the cursor to the new row.
+ // In order to focus the correct element, we need the DOM to be in sync
+ // with the issue list. We modified this.issues, so wait for a re-render.
+ await this.updateComplete;
+ const selector = `.list-row[data-index="${finalIndex}"]`;
+ this.shadowRoot.querySelector(selector).focus();
+ }
+
+ /**
+ * Computes the starting and ending indices of the cursor row,
+ * given how far the mouse has been dragged in the y-direction.
+ * The indices assume the cursor row has been removed from the list.
+ * @param {number} y The y-distance the cursor has moved since mouseDown.
+ * @return {[Array<HTMLTableRowElement>, number, number]} A tuple containing:
+ * An Array of table rows with the cursor row removed.
+ * The initial index of the cursor row.
+ * The final index of the cursor row.
+ * @private
+ */
+ _computeRerank(y) {
+ const row = this._getCursorElement();
+ const rows = this._getRows();
+ const listTop = row.parentNode.offsetTop;
+
+ // Find the initial index of the cursor row.
+ // TODO(dtu): If we support multi-drag, this should be the adjusted index of
+ // the first selected row after collapsing spaces in the selected group.
+ const initialIndex = rows.indexOf(row);
+ rows.splice(initialIndex, 1);
+
+ // Compute the initial and final y-positions of the top
+ // of the cursor row relative to the top of the list.
+ const initialY = row.offsetTop - listTop;
+ const finalY = initialY + y;
+
+ // Compute the final index of the cursor row.
+ // The break points are the halfway marks of each row.
+ let finalIndex = 0;
+ for (finalIndex = 0; finalIndex < rows.length; ++finalIndex) {
+ const rowTop = rows[finalIndex].offsetTop - listTop -
+ (finalIndex >= initialIndex ? row.scrollHeight : 0);
+ const breakpoint = rowTop + rows[finalIndex].scrollHeight / 2;
+ if (breakpoint > finalY) {
+ break;
+ }
+ }
+
+ return [rows, initialIndex, finalIndex];
+ }
+
+ /**
+ * @param {Array<HTMLTableRowElement>} rows Array of table rows with the
+ * cursor row removed.
+ * @param {number} initialIndex The initial index of the cursor row.
+ * @param {number} finalIndex The final index of the cursor row.
+ * @return {number} The number of pixels the cursor row moved.
+ * @private
+ */
+ _translateRows(rows, initialIndex, finalIndex) {
+ const firstIndex = Math.min(initialIndex, finalIndex);
+ const lastIndex = Math.max(initialIndex, finalIndex);
+
+ const rowHeight = this._getCursorElement().scrollHeight;
+ const translation = initialIndex < finalIndex ? -rowHeight : rowHeight;
+
+ let targetTranslation = 0;
+ for (let i = 0; i < rows.length; ++i) {
+ rows[i].style.transition = EASE_OUT_TRANSITION;
+ if (i >= firstIndex && i < lastIndex) {
+ rows[i].style.transform = `translate(0px, ${translation}px)`;
+ targetTranslation += rows[i].scrollHeight;
+ } else {
+ rows[i].style.transform = '';
+ }
+ }
+
+ return initialIndex < finalIndex ? targetTranslation : -targetTranslation;
+ }
+
+ /**
+ * Handle click and auxclick on issue row.
+ * @param {MouseEvent} event
+ * @private
+ */
+ _clickIssueRow(event) {
+ if (event.button === PRIMARY_BUTTON || event.button === MIDDLE_BUTTON) {
+ this._maybeOpenIssueRow(
+ event, /* openNewTab= */ event.button === MIDDLE_BUTTON);
+ }
+ }
+
+ /**
+ * Checks that the given event should not be ignored, then navigates to the
+ * issue associated with the row.
+ *
+ * @param {MouseEvent|KeyboardEvent} rowEvent A click or 'enter' on a row.
+ * @param {boolean=} openNewTab Forces opening in a new tab
+ * @private
+ */
+ _maybeOpenIssueRow(rowEvent, openNewTab = false) {
+ const path = rowEvent.composedPath();
+ const containsIgnoredElement = path.find(
+ (node) => (node.tagName || '').toUpperCase() === 'A' ||
+ (node.classList && node.classList.contains('ignore-navigation')));
+ if (containsIgnoredElement) return;
+
+ const row = /** @type {HTMLTableRowElement} */ (rowEvent.currentTarget);
+
+ const i = Number.parseInt(row.dataset.index);
+
+ if (i >= 0 && i < this.issues.length) {
+ this._navigateToIssue(this.issues[i], openNewTab || rowEvent.metaKey ||
+ rowEvent.ctrlKey);
+ }
+ }
+
+ /**
+ * @param {Issue} issue
+ * @param {boolean} newTab
+ * @private
+ */
+ _navigateToIssue(issue, newTab) {
+ const link = issueRefToUrl(issueToIssueRef(issue),
+ this._queryParams);
+
+ if (newTab) {
+ // Whether the link opens in a new tab or window is based on the
+ // user's browser preferences.
+ window.open(link, '_blank', 'noopener');
+ } else {
+ this._page(link);
+ }
+ }
+
+ /**
+ * Convert an issue's data into an array of strings, where the columns
+ * match this.columns. Extracting data like _renderCell.
+ * @param {Issue} issue
+ * @return {Array<string>}
+ * @private
+ */
+ _convertIssueToPlaintextArray(issue) {
+ return this.columns.map((column) => {
+ return this.extractFieldValues(issue, column).join(', ');
+ });
+ }
+
+ /**
+ * Convert each Issue into array of strings, where the columns
+ * match this.columns.
+ * @return {Array<Array<string>>}
+ * @private
+ */
+ _convertIssuesToPlaintextArrays() {
+ return this.issues.map(this._convertIssueToPlaintextArray.bind(this));
+ }
+
+ /**
+ * Download content as csv. Conversion to CSV only on button click
+ * instead of on data change because CSV download is not often used.
+ * @param {MouseEvent} event
+ * @private
+ */
+ async _downloadCsv(event) {
+ event.preventDefault();
+
+ if (this.userDisplayName) {
+ // convert issues to array of arrays of strings
+ const issueData = this._convertIssuesToPlaintextArrays();
+
+ // convert the data into csv formatted string.
+ const csvDataString = prepareDataForDownload(issueData, this.columns);
+
+ // construct data href
+ const href = constructHref(csvDataString);
+
+ // modify a tag's href
+ this._csvDataHref = href;
+ await this.requestUpdate('_csvDataHref');
+
+ // click to trigger download
+ this._dataLink.click();
+
+ // reset dataHref
+ this._csvDataHref = '';
+ }
+ }
+};
+
+customElements.define('mr-issue-list', MrIssueList);
diff --git a/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js b/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js
new file mode 100644
index 0000000..3861e32
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js
@@ -0,0 +1,1328 @@
+// 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 * as projectV0 from 'reducers/projectV0.js';
+import {stringValuesForIssueField} from 'shared/issue-fields.js';
+import {MrIssueList} from './mr-issue-list.js';
+
+let element;
+
+const listRowIsFocused = (element, i) => {
+ const focused = element.shadowRoot.activeElement;
+ assert.equal(focused.tagName.toUpperCase(), 'TR');
+ assert.equal(focused.dataset.index, `${i}`);
+};
+
+describe('mr-issue-list', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-list');
+ element.extractFieldValues = projectV0.extractFieldValuesFromIssue({});
+ document.body.appendChild(element);
+
+ sinon.stub(element, '_baseUrl').returns('/p/chromium/issues/list');
+ sinon.stub(element, '_page');
+ sinon.stub(window, 'open');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ window.open.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueList);
+ });
+
+ it('issue summaries render', async () => {
+ element.issues = [
+ {summary: 'test issue'},
+ {summary: 'I have a summary'},
+ ];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ const summaries = element.shadowRoot.querySelectorAll('.col-summary');
+
+ assert.equal(summaries.length, 2);
+
+ assert.equal(summaries[0].textContent.trim(), 'test issue');
+ assert.equal(summaries[1].textContent.trim(), 'I have a summary');
+ });
+
+ it('one word labels render in summary column', async () => {
+ element.issues = [
+ {
+ projectName: 'test',
+ localId: 1,
+ summary: 'test issue',
+ labelRefs: [
+ {label: 'ignore-multi-word-labels'},
+ {label: 'Security'},
+ {label: 'A11y'},
+ ],
+ },
+ ];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ const summary = element.shadowRoot.querySelector('.col-summary');
+ const labels = summary.querySelectorAll('.summary-label');
+
+ assert.equal(labels.length, 2);
+
+ assert.equal(labels[0].textContent.trim(), 'Security');
+ assert.include(labels[0].href,
+ '/p/test/issues/list?q=label%3ASecurity');
+ assert.equal(labels[1].textContent.trim(), 'A11y');
+ assert.include(labels[1].href,
+ '/p/test/issues/list?q=label%3AA11y');
+ });
+
+ it('blocking column renders issue links', async () => {
+ element.issues = [
+ {
+ projectName: 'test',
+ localId: 1,
+ blockingIssueRefs: [
+ {projectName: 'test', localId: 2},
+ {projectName: 'test', localId: 3},
+ ],
+ },
+ ];
+ element.columns = ['Blocking'];
+
+ await element.updateComplete;
+
+ const blocking = element.shadowRoot.querySelector('.col-blocking');
+ const link = blocking.querySelector('mr-issue-link');
+ assert.equal(link.href, '/p/test/issues/detail?id=2');
+ });
+
+ it('blockedOn column renders issue links', async () => {
+ element.issues = [
+ {
+ projectName: 'test',
+ localId: 1,
+ blockedOnIssueRefs: [{projectName: 'test', localId: 2}],
+ },
+ ];
+ element.columns = ['BlockedOn'];
+
+ await element.updateComplete;
+
+ const blocking = element.shadowRoot.querySelector('.col-blockedon');
+ const link = blocking.querySelector('mr-issue-link');
+ assert.equal(link.href, '/p/test/issues/detail?id=2');
+ });
+
+ it('mergedInto column renders issue link', async () => {
+ element.issues = [
+ {
+ projectName: 'test',
+ localId: 1,
+ mergedIntoIssueRef: {projectName: 'test', localId: 2},
+ },
+ ];
+ element.columns = ['MergedInto'];
+
+ await element.updateComplete;
+
+ const blocking = element.shadowRoot.querySelector('.col-mergedinto');
+ const link = blocking.querySelector('mr-issue-link');
+ assert.equal(link.href, '/p/test/issues/detail?id=2');
+ });
+
+ it('clicking issue link does not trigger _navigateToIssue', async () => {
+ sinon.stub(element, '_navigateToIssue');
+
+ // Prevent the page from actually navigating on the link click.
+ const clickIntercepter = sinon.spy((e) => {
+ e.preventDefault();
+ });
+ window.addEventListener('click', clickIntercepter);
+
+ element.issues = [
+ {projectName: 'test', localId: 1, summary: 'test issue'},
+ {projectName: 'test', localId: 2, summary: 'I have a summary'},
+ ];
+ element.columns = ['ID'];
+
+ await element.updateComplete;
+
+ const idLink = element.shadowRoot.querySelector('.col-id > mr-issue-link');
+
+ idLink.click();
+
+ sinon.assert.calledOnce(clickIntercepter);
+ sinon.assert.notCalled(element._navigateToIssue);
+
+ window.removeEventListener('click', clickIntercepter);
+ });
+
+ it('clicking issue row opens issue', async () => {
+ element.issues = [{
+ summary: 'click me',
+ localId: 22,
+ projectName: 'chromium',
+ }];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ const rowChild = element.shadowRoot.querySelector('.col-summary');
+ rowChild.click();
+
+ sinon.assert.calledWith(element._page, '/p/chromium/issues/detail?id=22');
+ sinon.assert.notCalled(window.open);
+ });
+
+ it('ctrl+click on row opens issue in new tab', async () => {
+ element.issues = [{
+ summary: 'click me',
+ localId: 24,
+ projectName: 'chromium',
+ }];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ const rowChild = element.shadowRoot.querySelector('.col-summary');
+ rowChild.dispatchEvent(new MouseEvent('click',
+ {ctrlKey: true, bubbles: true}));
+
+ sinon.assert.calledWith(window.open,
+ '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+ });
+
+ it('meta+click on row opens issue in new tab', async () => {
+ element.issues = [{
+ summary: 'click me',
+ localId: 24,
+ projectName: 'chromium',
+ }];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ const rowChild = element.shadowRoot.querySelector('.col-summary');
+ rowChild.dispatchEvent(new MouseEvent('click',
+ {metaKey: true, bubbles: true}));
+
+ sinon.assert.calledWith(window.open,
+ '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+ });
+
+ it('mouse wheel click on row opens issue in new tab', async () => {
+ element.issues = [{
+ summary: 'click me',
+ localId: 24,
+ projectName: 'chromium',
+ }];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ const rowChild = element.shadowRoot.querySelector('.col-summary');
+ rowChild.dispatchEvent(new MouseEvent('auxclick',
+ {button: 1, bubbles: true}));
+
+ sinon.assert.calledWith(window.open,
+ '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+ });
+
+ it('right click on row does not navigate', async () => {
+ element.issues = [{
+ summary: 'click me',
+ localId: 24,
+ projectName: 'chromium',
+ }];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ const rowChild = element.shadowRoot.querySelector('.col-summary');
+ rowChild.dispatchEvent(new MouseEvent('auxclick',
+ {button: 2, bubbles: true}));
+
+ sinon.assert.notCalled(window.open);
+ });
+
+ it('AllLabels column renders', async () => {
+ element.issues = [
+ {labelRefs: [{label: 'test'}, {label: 'hello-world'}]},
+ {labelRefs: [{label: 'one-label'}]},
+ ];
+
+ element.columns = ['AllLabels'];
+
+ await element.updateComplete;
+
+ const labels = element.shadowRoot.querySelectorAll('.col-alllabels');
+
+ assert.equal(labels.length, 2);
+
+ assert.equal(labels[0].textContent.trim(), 'test, hello-world');
+ assert.equal(labels[1].textContent.trim(), 'one-label');
+ });
+
+ it('issues sorted into groups when groups defined', async () => {
+ element.issues = [
+ {ownerRef: {displayName: 'test@example.com'}},
+ {ownerRef: {displayName: 'test@example.com'}},
+ {ownerRef: {displayName: 'other.user@example.com'}},
+ {},
+ ];
+
+ element.columns = ['Owner'];
+ element.groups = ['Owner'];
+
+ await element.updateComplete;
+
+ const owners = element.shadowRoot.querySelectorAll('.col-owner');
+ assert.equal(owners.length, 4);
+
+ const groupHeaders = element.shadowRoot.querySelectorAll(
+ '.group-header');
+ assert.equal(groupHeaders.length, 3);
+
+ assert.include(groupHeaders[0].textContent,
+ '2 issues: Owner=test@example.com');
+ assert.include(groupHeaders[1].textContent,
+ '1 issue: Owner=other.user@example.com');
+ assert.include(groupHeaders[2].textContent, '1 issue: -has:Owner');
+ });
+
+ it('toggling group hides members', async () => {
+ element.issues = [
+ {ownerRef: {displayName: 'group1@example.com'}},
+ {ownerRef: {displayName: 'group2@example.com'}},
+ ];
+
+ element.columns = ['Owner'];
+ element.groups = ['Owner'];
+
+ await element.updateComplete;
+
+ const issueRows = element.shadowRoot.querySelectorAll('.list-row');
+ assert.equal(issueRows.length, 2);
+
+ assert.isFalse(issueRows[0].hidden);
+ assert.isFalse(issueRows[1].hidden);
+
+ const groupHeaders = element.shadowRoot.querySelectorAll(
+ '.group-header');
+ assert.equal(groupHeaders.length, 2);
+
+ // Toggle first group hidden.
+ groupHeaders[0].click();
+ await element.updateComplete;
+
+ assert.isTrue(issueRows[0].hidden);
+ assert.isFalse(issueRows[1].hidden);
+ });
+
+ it('reloadColspec navigates to page with new colspec', () => {
+ element.columns = ['ID', 'Summary'];
+ element._queryParams = {};
+
+ element.reloadColspec(['Summary', 'AllLabels']);
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?colspec=Summary%2BAllLabels');
+ });
+
+ it('updateSortSpec navigates to page with new sort option', async () => {
+ element.columns = ['ID', 'Summary'];
+ element._queryParams = {};
+
+ await element.updateComplete;
+
+ element.updateSortSpec('Summary', true);
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?sort=-summary');
+ });
+
+ it('updateSortSpec navigates to first page when on later page', async () => {
+ element.columns = ['ID', 'Summary'];
+ element._queryParams = {start: '100', q: 'owner:me'};
+
+ await element.updateComplete;
+
+ element.updateSortSpec('Summary', true);
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?q=owner%3Ame&sort=-summary');
+ });
+
+ it('updateSortSpec prepends new option to existing sort', async () => {
+ element.columns = ['ID', 'Summary', 'Owner'];
+ element._queryParams = {sort: '-summary+owner'};
+
+ await element.updateComplete;
+
+ element.updateSortSpec('ID');
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?sort=id%20-summary%20owner');
+ });
+
+ it('updateSortSpec removes existing instances of sorted column', async () => {
+ element.columns = ['ID', 'Summary', 'Owner'];
+ element._queryParams = {sort: '-summary+owner+owner'};
+
+ await element.updateComplete;
+
+ element.updateSortSpec('Owner', true);
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?sort=-owner%20-summary');
+ });
+
+ it('_uniqueValuesByColumn re-computed when columns update', async () => {
+ element.issues = [
+ {id: 1, projectName: 'chromium'},
+ {id: 2, projectName: 'chromium'},
+ {id: 3, projectName: 'chrOmiUm'},
+ {id: 1, projectName: 'other'},
+ ];
+ element.columns = [];
+ await element.updateComplete;
+
+ assert.deepEqual(element._uniqueValuesByColumn, new Map());
+
+ element.columns = ['project'];
+ await element.updateComplete;
+
+ assert.deepEqual(element._uniqueValuesByColumn,
+ new Map([['project', new Set(['chromium', 'chrOmiUm', 'other'])]]));
+ });
+
+ it('showOnly adds new search term to query', async () => {
+ element.currentQuery = 'owner:me';
+ element._queryParams = {};
+
+ await element.updateComplete;
+
+ element.showOnly('Priority', 'High');
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?q=owner%3Ame%20priority%3DHigh');
+ });
+
+ it('addColumn adds a column', () => {
+ element.columns = ['ID', 'Summary'];
+
+ sinon.stub(element, 'reloadColspec');
+
+ element.addColumn('AllLabels');
+
+ sinon.assert.calledWith(element.reloadColspec,
+ ['ID', 'Summary', 'AllLabels']);
+ });
+
+ it('removeColumn removes a column', () => {
+ element.columns = ['ID', 'Summary'];
+
+ sinon.stub(element, 'reloadColspec');
+
+ element.removeColumn(0);
+
+ sinon.assert.calledWith(element.reloadColspec, ['Summary']);
+ });
+
+ it('clicking hide column in column header removes column', async () => {
+ element.columns = ['ID', 'Summary'];
+
+ sinon.stub(element, 'removeColumn');
+
+ await element.updateComplete;
+
+ const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+ dropdown.clickItem(0); // Hide column.
+
+ sinon.assert.calledWith(element.removeColumn, 1);
+ });
+
+ it('starring disabled when starringEnabled is false', async () => {
+ element.starringEnabled = false;
+ element.issues = [
+ {projectName: 'test', localId: 1, summary: 'test issue'},
+ {projectName: 'test', localId: 2, summary: 'I have a summary'},
+ ];
+
+ await element.updateComplete;
+
+ let stars = element.shadowRoot.querySelectorAll('mr-issue-star');
+ assert.equal(stars.length, 0);
+
+ element.starringEnabled = true;
+ await element.updateComplete;
+
+ stars = element.shadowRoot.querySelectorAll('mr-issue-star');
+ assert.equal(stars.length, 2);
+ });
+
+ describe('issue sorting and grouping enabled', () => {
+ beforeEach(() => {
+ element.sortingAndGroupingEnabled = true;
+ });
+
+ it('clicking sort up column header sets sort spec', async () => {
+ element.columns = ['ID', 'Summary'];
+
+ sinon.stub(element, 'updateSortSpec');
+
+ await element.updateComplete;
+
+ const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+ dropdown.clickItem(0); // Sort up.
+
+ sinon.assert.calledWith(element.updateSortSpec, 'Summary');
+ });
+
+ it('clicking sort down column header sets sort spec', async () => {
+ element.columns = ['ID', 'Summary'];
+
+ sinon.stub(element, 'updateSortSpec');
+
+ await element.updateComplete;
+
+ const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+ dropdown.clickItem(1); // Sort down.
+
+ sinon.assert.calledWith(element.updateSortSpec, 'Summary', true);
+ });
+
+ it('clicking group rows column header groups rows', async () => {
+ element.columns = ['Owner', 'Priority'];
+ element.groups = ['Status'];
+
+ sinon.spy(element, 'addGroupBy');
+
+ await element.updateComplete;
+
+ const dropdown = element.shadowRoot.querySelector('.dropdown-owner');
+ dropdown.clickItem(3); // Group rows.
+
+ sinon.assert.calledWith(element.addGroupBy, 0);
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?groupby=Owner%20Status&colspec=Priority');
+ });
+ });
+
+ describe('issue selection', () => {
+ beforeEach(() => {
+ element.selectionEnabled = true;
+ });
+
+ it('selections disabled when selectionEnabled is false', async () => {
+ element.selectionEnabled = false;
+ element.issues = [
+ {projectName: 'test', localId: 1, summary: 'test issue'},
+ {projectName: 'test', localId: 2, summary: 'I have a summary'},
+ ];
+
+ await element.updateComplete;
+
+ let checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+ assert.equal(checkboxes.length, 0);
+
+ element.selectionEnabled = true;
+ await element.updateComplete;
+
+ checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+ assert.equal(checkboxes.length, 2);
+ });
+
+ it('selected issues render selected attribute', async () => {
+ element.issues = [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'another issue', localId: 2, projectName: 'proj'},
+ {summary: 'issue 2', localId: 3, projectName: 'proj'},
+ ];
+ element.columns = ['Summary'];
+
+ await element.updateComplete;
+
+ element._selectedIssues = new Set(['proj:1']);
+
+ await element.updateComplete;
+
+ const issues = element.shadowRoot.querySelectorAll('tr[selected]');
+
+ assert.equal(issues.length, 1);
+ assert.equal(issues[0].dataset.index, '0');
+ assert.include(issues[0].textContent, 'issue 1');
+ });
+
+ it('select all / none conditionally shows tooltip', async () => {
+ element.issues = [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'issue 2', localId: 2, projectName: 'proj'},
+ ];
+
+ await element.updateComplete;
+ assert.deepEqual(element.selectedIssues, []);
+
+ const selectAll = element.shadowRoot.querySelector('.select-all');
+
+ // No issues selected, offer "Select All".
+ assert.equal(selectAll.title, 'Select All');
+ assert.equal(selectAll.getAttribute('aria-label'), 'Select All');
+
+ selectAll.click();
+
+ await element.updateComplete;
+
+ // Some issues selected, offer "Select None".
+ assert.equal(selectAll.title, 'Select None');
+ assert.equal(selectAll.getAttribute('aria-label'), 'Select None');
+ });
+
+ it('clicking select all selects all issues', async () => {
+ element.issues = [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'issue 2', localId: 2, projectName: 'proj'},
+ ];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, []);
+
+ const selectAll = element.shadowRoot.querySelector('.select-all');
+ selectAll.click();
+
+ assert.deepEqual(element.selectedIssues, [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'issue 2', localId: 2, projectName: 'proj'},
+ ]);
+ });
+
+ it('when checked select all deselects all issues', async () => {
+ element.issues = [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'issue 2', localId: 2, projectName: 'proj'},
+ ];
+
+ await element.updateComplete;
+
+ element._selectedIssues = new Set(['proj:1', 'proj:2']);
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'issue 2', localId: 2, projectName: 'proj'},
+ ]);
+
+ const selectAll = element.shadowRoot.querySelector('.select-all');
+ selectAll.click();
+
+ assert.deepEqual(element.selectedIssues, []);
+ });
+
+ it('selected issues added when issues checked', async () => {
+ element.issues = [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'another issue', localId: 2, projectName: 'proj'},
+ {summary: 'issue 2', localId: 3, projectName: 'proj'},
+ ];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, []);
+
+ const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+
+ assert.equal(checkboxes.length, 3);
+
+ checkboxes[2].dispatchEvent(new MouseEvent('click'));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, [
+ {summary: 'issue 2', localId: 3, projectName: 'proj'},
+ ]);
+
+ checkboxes[0].dispatchEvent(new MouseEvent('click'));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, [
+ {summary: 'issue 1', localId: 1, projectName: 'proj'},
+ {summary: 'issue 2', localId: 3, projectName: 'proj'},
+ ]);
+ });
+
+ it('shift+click selects issues in a range', async () => {
+ element.issues = [
+ {localId: 1, projectName: 'proj'},
+ {localId: 2, projectName: 'proj'},
+ {localId: 3, projectName: 'proj'},
+ {localId: 4, projectName: 'proj'},
+ {localId: 5, projectName: 'proj'},
+ ];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, []);
+
+ const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+
+ // First click.
+ checkboxes[0].dispatchEvent(new MouseEvent('click'));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, [
+ {localId: 1, projectName: 'proj'},
+ ]);
+
+ // Second click.
+ checkboxes[3].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, [
+ {localId: 1, projectName: 'proj'},
+ {localId: 2, projectName: 'proj'},
+ {localId: 3, projectName: 'proj'},
+ {localId: 4, projectName: 'proj'},
+ ]);
+
+ // It's possible to chain Shift+Click operations.
+ checkboxes[2].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, [
+ {localId: 1, projectName: 'proj'},
+ {localId: 2, projectName: 'proj'},
+ ]);
+ });
+
+ it('fires selectionChange events', async () => {
+ const listener = sinon.stub();
+ element.addEventListener('selectionChange', listener);
+
+ // Changing the issue list clears the selection and fires an event.
+ element.issues = [{localId: 1, projectName: 'proj'}];
+ await element.updateComplete;
+ // Selecting all/deselecting all fires an event.
+ element.shadowRoot.querySelector('.select-all').click();
+ await element.updateComplete;
+ // Selecting an individual issue fires an event.
+ element.shadowRoot.querySelectorAll('.issue-checkbox')[0].click();
+
+ sinon.assert.calledThrice(listener);
+ });
+ });
+
+ describe('cursor', () => {
+ beforeEach(() => {
+ element.issues = [
+ {localId: 1, projectName: 'chromium'},
+ {localId: 2, projectName: 'chromium'},
+ ];
+ });
+
+ it('empty when no initialCursor', () => {
+ assert.deepEqual(element.cursor, {});
+
+ element.initialCursor = '';
+ assert.deepEqual(element.cursor, {});
+ });
+
+ it('parses initialCursor value', () => {
+ element.initialCursor = '1';
+ element.projectName = 'chromium';
+
+ assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
+
+ element.initialCursor = 'chromium:1';
+ assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
+ });
+
+ it('overrides initialCursor with _localCursor', () => {
+ element.initialCursor = 'chromium:1';
+ element._localCursor = {projectName: 'gerrit', localId: 2};
+
+ assert.deepEqual(element.cursor, {projectName: 'gerrit', localId: 2});
+ });
+
+ it('initialCursor renders cursor and focuses element', async () => {
+ element.initialCursor = 'chromium:1';
+
+ await element.updateComplete;
+
+ const row = element.shadowRoot.querySelector('.row-0');
+ assert.isTrue(row.hasAttribute('cursored'));
+ listRowIsFocused(element, 0);
+ });
+
+ it('cursor value updated when row is focused', async () => {
+ element.initialCursor = 'chromium:1';
+
+ await element.updateComplete;
+
+ // HTMLElement.focus() seems to cause a timing related flake here.
+ element.shadowRoot.querySelector('.row-1').dispatchEvent(
+ new Event('focus'));
+
+ assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 2});
+ });
+ });
+
+ describe('hot keys', () => {
+ beforeEach(() => {
+ element.issues = [
+ {localId: 1, projectName: 'chromium'},
+ {localId: 2, projectName: 'chromium'},
+ {localId: 3, projectName: 'chromium'},
+ ];
+
+ element.selectionEnabled = true;
+
+ sinon.stub(element, '_navigateToIssue');
+ });
+
+ afterEach(() => {
+ element._navigateToIssue.restore();
+ });
+
+ it('global keydown listener removed on disconnect', async () => {
+ sinon.stub(element, '_boundRunListHotKeys');
+
+ await element.updateComplete;
+
+ window.dispatchEvent(new Event('keydown'));
+ sinon.assert.calledOnce(element._boundRunListHotKeys);
+
+ document.body.removeChild(element);
+
+ window.dispatchEvent(new Event('keydown'));
+ sinon.assert.calledOnce(element._boundRunListHotKeys);
+
+ document.body.appendChild(element);
+ });
+
+ it('pressing j defaults to first issue', async () => {
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+ listRowIsFocused(element, 0);
+ });
+
+ it('pressing j focuses next issue', async () => {
+ element.initialCursor = 'chromium:1';
+
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+ listRowIsFocused(element, 1);
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+ listRowIsFocused(element, 2);
+ });
+
+ it('pressing j at the end of the list loops around', async () => {
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('.row-2').focus();
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+ listRowIsFocused(element, 0);
+ });
+
+
+ it('pressing k defaults to last issue', async () => {
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+ listRowIsFocused(element, 2);
+ });
+
+ it('pressing k focuses previous issue', async () => {
+ element.initialCursor = 'chromium:3';
+
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+ listRowIsFocused(element, 1);
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+ listRowIsFocused(element, 0);
+ });
+
+ it('pressing k at the start of the list loops around', async () => {
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('.row-0').focus();
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+ listRowIsFocused(element, 2);
+ });
+
+ it('j and k keys treat row as focused if child is focused', async () => {
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('.row-1').querySelector(
+ 'mr-issue-link').focus();
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+ listRowIsFocused(element, 2);
+
+ element.shadowRoot.querySelector('.row-1').querySelector(
+ 'mr-issue-link').focus();
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+ listRowIsFocused(element, 0);
+ });
+
+ it('j and k keys stay on one element when one issue', async () => {
+ element.issues = [{localId: 2, projectName: 'chromium'}];
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+ listRowIsFocused(element, 0);
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+ listRowIsFocused(element, 0);
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+ listRowIsFocused(element, 0);
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+ listRowIsFocused(element, 0);
+ });
+
+ it('j and k no-op when event is from input', async () => {
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+
+ await element.updateComplete;
+
+ input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+ assert.isNull(element.shadowRoot.activeElement);
+
+ input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+ assert.isNull(element.shadowRoot.activeElement);
+
+ document.body.removeChild(input);
+ });
+
+ it('j and k no-op when event is from shadowDOM input', async () => {
+ const input = document.createElement('input');
+ const root = document.createElement('div');
+
+ root.attachShadow({mode: 'open'});
+ root.shadowRoot.appendChild(input);
+
+ document.body.appendChild(root);
+
+ await element.updateComplete;
+
+ input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+ assert.isNull(element.shadowRoot.activeElement);
+
+ input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+ assert.isNull(element.shadowRoot.activeElement);
+
+ document.body.removeChild(root);
+ });
+
+ describe('starring issue', () => {
+ beforeEach(() => {
+ element.starringEnabled = true;
+ element.initialCursor = 'chromium:2';
+ });
+
+ it('pressing s stars focused issue', async () => {
+ sinon.stub(element, '_starIssue');
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 's'}));
+
+ sinon.assert.calledWith(element._starIssue,
+ {localId: 2, projectName: 'chromium'});
+ });
+
+ it('starIssue does not star issue while stars are fetched', () => {
+ sinon.stub(element, '_starIssueInternal');
+ element._fetchingStarredIssues = true;
+
+ element._starIssue({localId: 2, projectName: 'chromium'});
+
+ sinon.assert.notCalled(element._starIssueInternal);
+ });
+
+ it('starIssue does not star when issue is being starred', () => {
+ sinon.stub(element, '_starIssueInternal');
+ element._starringIssues = new Map([['chromium:2', {requesting: true}]]);
+
+ element._starIssue({localId: 2, projectName: 'chromium'});
+
+ sinon.assert.notCalled(element._starIssueInternal);
+ });
+
+ it('starIssue stars issue when issue is not being starred', () => {
+ sinon.stub(element, '_starIssueInternal');
+ element._starringIssues = new Map([
+ ['chromium:2', {requesting: false}],
+ ]);
+
+ element._starIssue({localId: 2, projectName: 'chromium'});
+
+ sinon.assert.calledWith(element._starIssueInternal,
+ {localId: 2, projectName: 'chromium'}, true);
+ });
+
+ it('starIssue unstars issue when issue is already starred', () => {
+ sinon.stub(element, '_starIssueInternal');
+ element._starredIssues = new Set(['chromium:2']);
+
+ element._starIssue({localId: 2, projectName: 'chromium'});
+
+ sinon.assert.calledWith(element._starIssueInternal,
+ {localId: 2, projectName: 'chromium'}, false);
+ });
+ });
+
+ it('pressing x selects focused issue', async () => {
+ element.initialCursor = 'chromium:2';
+
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'x'}));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.selectedIssues, [
+ {localId: 2, projectName: 'chromium'},
+ ]);
+ });
+
+ it('pressing o navigates to focused issue', async () => {
+ element.initialCursor = 'chromium:2';
+
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'o'}));
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._navigateToIssue);
+ sinon.assert.calledWith(element._navigateToIssue,
+ {localId: 2, projectName: 'chromium'}, false);
+ });
+
+ it('pressing shift+o opens focused issue in new tab', async () => {
+ element.initialCursor = 'chromium:2';
+
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown',
+ {key: 'O', shiftKey: true}));
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._navigateToIssue);
+ sinon.assert.calledWith(element._navigateToIssue,
+ {localId: 2, projectName: 'chromium'}, true);
+ });
+
+ it('enter keydown on row navigates to issue', async () => {
+ await element.updateComplete;
+
+ const row = element.shadowRoot.querySelector('.row-1');
+
+ row.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._navigateToIssue);
+ sinon.assert.calledWith(
+ element._navigateToIssue, {localId: 2, projectName: 'chromium'},
+ false);
+ });
+
+ it('ctrl+enter keydown on row navigates to issue in new tab', async () => {
+ await element.updateComplete;
+
+ const row = element.shadowRoot.querySelector('.row-1');
+
+ // Note: metaKey would also work, but this is covered by click tests.
+ row.dispatchEvent(new KeyboardEvent(
+ 'keydown', {key: 'Enter', ctrlKey: true, bubbles: true}));
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._navigateToIssue);
+ sinon.assert.calledWith(element._navigateToIssue,
+ {localId: 2, projectName: 'chromium'}, true);
+ });
+
+ it('enter keypress outside row is ignored', async () => {
+ await element.updateComplete;
+
+ window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element._navigateToIssue);
+ });
+ });
+
+ describe('_convertIssueToPlaintextArray', () => {
+ it('returns an array with as many entries as this.columns.length', () => {
+ element.columns = ['summary'];
+ const result = element._convertIssueToPlaintextArray({
+ summary: 'test issue',
+ });
+ assert.equal(element.columns.length, result.length);
+ });
+
+ it('for column id uses issueRefToString', async () => {
+ const projectName = 'some_project_name';
+ const otherProjectName = 'some_other_project';
+ const localId = '123';
+ element.columns = ['ID'];
+ element.projectName = projectName;
+
+ element.extractFieldValues = (issue, fieldName) =>
+ stringValuesForIssueField(issue, fieldName, projectName);
+
+ let result;
+ result = element._convertIssueToPlaintextArray({
+ localId,
+ projectName,
+ });
+ assert.equal(localId, result[0]);
+
+ result = element._convertIssueToPlaintextArray({
+ localId,
+ projectName: otherProjectName,
+ });
+ assert.equal(`${otherProjectName}:${localId}`, result[0]);
+ });
+
+ it('uses extractFieldValues', () => {
+ element.columns = ['summary', 'notsummary', 'anotherColumn'];
+ element.extractFieldValues = sinon.fake.returns(['a', 'b']);
+
+ element._convertIssueToPlaintextArray({summary: 'test issue'});
+ sinon.assert.callCount(element.extractFieldValues,
+ element.columns.length);
+ });
+
+ it('joins the result of extractFieldValues with ", "', () => {
+ element.columns = ['notSummary'];
+ element.extractFieldValues = sinon.fake.returns(['a', 'b']);
+
+ const result = element._convertIssueToPlaintextArray({
+ summary: 'test issue',
+ });
+ assert.deepEqual(result, ['a, b']);
+ });
+ });
+
+ describe('_convertIssuesToPlaintextArrays', () => {
+ it('maps this.issues with this._convertIssueToPlaintextArray', () => {
+ element._convertIssueToPlaintextArray = sinon.fake.returns(['foobar']);
+
+ element.columns = ['summary'];
+ element.issues = [
+ {summary: 'test issue'},
+ {summary: 'I have a summary'},
+ ];
+ const result = element._convertIssuesToPlaintextArrays();
+
+ assert.deepEqual([['foobar'], ['foobar']], result);
+ sinon.assert.callCount(element._convertIssueToPlaintextArray,
+ element.issues.length);
+ });
+ });
+
+ it('drag-and-drop', async () => {
+ element.rerank = () => {};
+ element.issues = [
+ {projectName: 'project', localId: 123, summary: 'test issue'},
+ {projectName: 'project', localId: 456, summary: 'I have a summary'},
+ {projectName: 'project', localId: 789, summary: 'third issue'},
+ ];
+ await element.updateComplete;
+
+ const rows = element._getRows();
+
+ // Mouse down on the middle element!
+ const secondRow = rows[1];
+ const dragHandle = secondRow.firstElementChild;
+ const mouseDown = new MouseEvent('mousedown', {clientX: 0, clientY: 0});
+ dragHandle.dispatchEvent(mouseDown);
+
+ assert.deepEqual(element._dragging, true);
+ assert.deepEqual(element.cursor, {projectName: 'project', localId: 456});
+ assert.deepEqual(element.selectedIssues, [element.issues[1]]);
+
+ // Drag the middle element to the end!
+ const mouseMove = new MouseEvent('mousemove', {clientX: 0, clientY: 100});
+ window.dispatchEvent(mouseMove);
+
+ assert.deepEqual(rows[0].style['transform'], '');
+ assert.deepEqual(rows[1].style['transform'], 'translate(0px, 100px)');
+ assert.match(rows[2].style['transform'], /^translate\(0px, -\d+px\)$/);
+
+ // Mouse up!
+ const mouseUp = new MouseEvent('mouseup', {clientX: 0, clientY: 100});
+ window.dispatchEvent(mouseUp);
+
+ assert.deepEqual(element._dragging, false);
+ assert.match(rows[1].style['transform'], /^translate\(0px, \d+px\)$/);
+ });
+
+ describe('CSV download', () => {
+ let _downloadCsvSpy;
+ let convertStub;
+
+ beforeEach(() => {
+ element.userDisplayName = 'notempty';
+ _downloadCsvSpy = sinon.spy(element, '_downloadCsv');
+ convertStub = sinon
+ .stub(element, '_convertIssuesToPlaintextArrays')
+ .returns([['']]);
+ });
+
+ afterEach(() => {
+ _downloadCsvSpy.restore();
+ convertStub.restore();
+ });
+
+ it('hides download link for anonymous users', async () => {
+ element.userDisplayName = '';
+ await element.updateComplete;
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+ assert.isNull(downloadLink);
+ });
+
+ it('renders a #download-link', async () => {
+ await element.updateComplete;
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+ assert.isNotNull(downloadLink);
+ assert.equal('inline', window.getComputedStyle(downloadLink).display);
+ });
+
+ it('renders a #hidden-data-link', async () => {
+ await element.updateComplete;
+ assert.isNotNull(element._dataLink);
+ const expected = element.shadowRoot.querySelector('#hidden-data-link');
+ assert.equal(expected, element._dataLink);
+ });
+
+ it('hides #hidden-data-link', async () => {
+ await element.updateComplete;
+ const _dataLink = element.shadowRoot.querySelector('#hidden-data-link');
+ assert.equal('none', window.getComputedStyle(_dataLink).display);
+ });
+
+ it('calls _downloadCsv on click', async () => {
+ await element.updateComplete;
+ sinon.stub(element._dataLink, 'click');
+
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+ downloadLink.click();
+ await element.requestUpdate('_csvDataHref');
+
+ sinon.assert.calledOnce(_downloadCsvSpy);
+ element._dataLink.click.restore();
+ });
+
+ it('converts issues into arrays of plaintext data', async () => {
+ await element.updateComplete;
+ sinon.stub(element._dataLink, 'click');
+
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+ downloadLink.click();
+ await element.requestUpdate('_csvDataHref');
+
+ sinon.assert.calledOnce(convertStub);
+ element._dataLink.click.restore();
+ });
+
+ it('triggers _dataLink click after #downloadLink click', async () => {
+ await element.updateComplete;
+ const dataLinkStub = sinon.stub(element._dataLink, 'click');
+
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+ downloadLink.click();
+
+ await element.requestUpdate('_csvDataHref');
+ sinon.assert.calledOnce(dataLinkStub);
+
+ element._dataLink.click.restore();
+ });
+
+ it('triggers _csvDataHref update and _dataLink click', async () => {
+ await element.updateComplete;
+ assert.equal('', element._csvDataHref);
+ const downloadStub = sinon.stub(element._dataLink, 'click');
+
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+ downloadLink.click();
+ assert.notEqual('', element._csvDataHref);
+ await element.requestUpdate('_csvDataHref');
+ sinon.assert.calledOnce(downloadStub);
+
+ element._dataLink.click.restore();
+ });
+
+ it('resets _csvDataHref', async () => {
+ await element.updateComplete;
+ assert.equal('', element._csvDataHref);
+
+ sinon.stub(element._dataLink, 'click');
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+ downloadLink.click();
+ assert.notEqual('', element._csvDataHref);
+
+ await element.requestUpdate('_csvDataHref');
+ assert.equal('', element._csvDataHref);
+ element._dataLink.click.restore();
+ });
+
+ it('does nothing for anonymous users', async () => {
+ await element.updateComplete;
+
+ element.userDisplayName = '';
+
+ const downloadStub = sinon.stub(element._dataLink, 'click');
+
+ const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+ downloadLink.click();
+ await element.requestUpdate('_csvDataHref');
+ sinon.assert.notCalled(downloadStub);
+
+ element._dataLink.click.restore();
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js
new file mode 100644
index 0000000..5d6a97b
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js
@@ -0,0 +1,310 @@
+// 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 {css} from 'lit-element';
+import {MrDropdown} from 'elements/framework/mr-dropdown/mr-dropdown.js';
+import page from 'page';
+import qs from 'qs';
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {fieldTypes, fieldsForIssue} from 'shared/issue-fields.js';
+
+
+/**
+ * `<mr-show-columns-dropdown>`
+ *
+ * Issue list column options dropdown.
+ *
+ */
+export class MrShowColumnsDropdown extends connectStore(MrDropdown) {
+ /** @override */
+ static get styles() {
+ return [
+ ...MrDropdown.styles,
+ css`
+ :host {
+ font-weight: normal;
+ color: var(--chops-link-color);
+ --mr-dropdown-icon-color: var(--chops-link-color);
+ --mr-dropdown-anchor-padding: 3px 8px;
+ --mr-dropdown-anchor-font-weight: bold;
+ --mr-dropdown-menu-min-width: 150px;
+ --mr-dropdown-menu-font-size: var(--chops-main-font-size);
+ --mr-dropdown-menu-icon-size: var(--chops-main-font-size);
+ /* Because we're using a sticky header, we need to make sure the
+ * dropdown cannot be taller than the screen. */
+ --mr-dropdown-menu-max-height: 80vh;
+ --mr-dropdown-menu-overflow: auto;
+ }
+ `,
+ ];
+ }
+ /** @override */
+ static get properties() {
+ return {
+ ...MrDropdown.properties,
+ /**
+ * Array of displayed columns.
+ */
+ columns: {type: Array},
+ /**
+ * Array of displayed issues.
+ */
+ issues: {type: Array},
+ /**
+ * Array of unique phase names to prepend to phase field columns.
+ */
+ // TODO(dtu): Delete after removing EZT hotlist issue list.
+ phaseNames: {type: Array},
+ /**
+ * Array of built in fields that are available outside of project
+ * configuration.
+ */
+ defaultFields: {type: Array},
+ _fieldDefs: {type: Array},
+ _labelPrefixFields: {type: Array},
+ // TODO(zhangtiff): Delete this legacy integration after removing
+ // the EZT issue list view.
+ onHideColumn: {type: Object},
+ onShowColumn: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ // Inherited from MrDropdown.
+ this.label = 'Show columns';
+ this.icon = 'more_horiz';
+
+ this.columns = [];
+ /** @type {Array<Issue>} */
+ this.issues = [];
+ this.phaseNames = [];
+ this.defaultFields = [];
+
+ // TODO(dtu): Delete after removing EZT hotlist issue list.
+ this._fieldDefs = [];
+ this._labelPrefixFields = [];
+
+ this._queryParams = {};
+ this._page = page;
+
+ // TODO(zhangtiff): Delete this legacy integration after removing
+ // the EZT issue list view.
+ this.onHideColumn = null;
+ this.onShowColumn = null;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._fieldDefs = projectV0.fieldDefs(state) || [];
+ this._labelPrefixFields = projectV0.labelPrefixFields(state) || [];
+ this._queryParams = sitewide.queryParams(state);
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (this.issues.length) {
+ this.items = this.columnOptions();
+ } else {
+ // TODO(dtu): Delete after removing EZT hotlist issue list.
+ this.items = this.columnOptionsEzt(
+ this.defaultFields, this._fieldDefs, this._labelPrefixFields,
+ this.columns, this.phaseNames);
+ }
+
+ super.update(changedProperties);
+ }
+
+ /**
+ * Computes the column options available in the list view based on Issues.
+ * @return {Array<MenuItem>}
+ */
+ columnOptions() {
+ const availableFields = new Set(this.defaultFields);
+ this.issues.forEach((issue) => {
+ fieldsForIssue(issue).forEach((field) => {
+ availableFields.add(field);
+ });
+ });
+
+ // Remove selected columns from available fields.
+ this.columns.forEach((field) => availableFields.delete(field));
+ const sortedFields = [...availableFields].sort();
+
+ return [
+ // Show selected options first.
+ ...this.columns.map((field, i) => ({
+ icon: 'check',
+ text: field,
+ handler: () => this._removeColumn(i),
+ })),
+ // Unselected options come next.
+ ...sortedFields.map((field) => ({
+ icon: '',
+ text: field,
+ handler: () => this._addColumn(field),
+ })),
+ ];
+ }
+
+ // TODO(dtu): Delete after removing EZT hotlist issue list.
+ /**
+ * Computes the column options available in the list view based on project
+ * config data.
+ * @param {Array<string>} defaultFields List of built in columns.
+ * @param {Array<FieldDef>} fieldDefs List of custom fields configured in the
+ * viewed project.
+ * @param {Array<string>} labelPrefixes List of available label prefixes for
+ * the current project config..
+ * @param {Array<string>} selectedColumns List of columns the user is
+ * currently viewing.
+ * @param {Array<string>} phaseNames All phase namws present in the currently
+ * viewed issue list.
+ * @return {Array<MenuItem>}
+ */
+ columnOptionsEzt(defaultFields, fieldDefs, labelPrefixes, selectedColumns,
+ phaseNames) {
+ const selectedOptions = new Set(
+ selectedColumns.map((col) => col.toLowerCase()));
+
+ const availableFields = new Set();
+
+ // Built-in, hard-coded fields like Owner, Status, and Labels.
+ defaultFields.forEach((field) => this._addUnselectedField(
+ availableFields, field, selectedOptions));
+
+ // Custom fields.
+ fieldDefs.forEach((fd) => {
+ const {fieldRef, isPhaseField} = fd;
+ const {fieldName, type} = fieldRef;
+ if (isPhaseField) {
+ // If the custom field belongs to phases, prefix the phase name for
+ // each phase.
+ phaseNames.forEach((phaseName) => {
+ this._addUnselectedField(
+ availableFields, `${phaseName}.${fieldName}`, selectedOptions);
+ });
+ return;
+ }
+
+ // TODO(zhangtiff): Prefix custom fields with "approvalName" defined by
+ // the approval name after deprecating the old issue list page.
+
+ // Most custom fields can be directly added to the list with no
+ // modifications.
+ this._addUnselectedField(
+ availableFields, fieldName, selectedOptions);
+
+ // If the custom field is type approval, then it also has a built in
+ // "Approver" field.
+ if (type === fieldTypes.APPROVAL_TYPE) {
+ this._addUnselectedField(
+ availableFields, `${fieldName}-Approver`, selectedOptions);
+ }
+ });
+
+ // Fields inferred from label prefixes.
+ labelPrefixes.forEach((field) => this._addUnselectedField(
+ availableFields, field, selectedOptions));
+
+ const sortedFields = [...availableFields];
+ sortedFields.sort();
+
+ return [
+ ...selectedColumns.map((field, i) => ({
+ icon: 'check',
+ text: field,
+ handler: () => this._removeColumn(i),
+ })),
+ ...sortedFields.map((field) => ({
+ icon: '',
+ text: field,
+ handler: () => this._addColumn(field),
+ })),
+ ];
+ }
+
+ /**
+ * Helper that mutates a Set of column names in place, adding a given
+ * field only if it doesn't already show up in the list of selected
+ * fields.
+ * @param {Set<string>} availableFields Set of column names to mutate.
+ * @param {string} field Name of the field being added to the options.
+ * @param {Set<string>} selectedOptions Set of fieldNames that the user
+ * is viewing.
+ * @private
+ */
+ _addUnselectedField(availableFields, field, selectedOptions) {
+ if (!selectedOptions.has(field.toLowerCase())) {
+ availableFields.add(field);
+ }
+ }
+
+ /**
+ * Removes the column at a particular index.
+ *
+ * @param {number} i the issue column to be removed.
+ */
+ _removeColumn(i) {
+ if (this.onHideColumn) {
+ if (!this.onHideColumn(this.columns[i])) {
+ return;
+ }
+ }
+ const columns = [...this.columns];
+ columns.splice(i, 1);
+ this._reloadColspec(columns);
+ }
+
+ /**
+ * Adds a new column to a particular index.
+ *
+ * @param {string} name of the new column added.
+ */
+ _addColumn(name) {
+ if (this.onShowColumn) {
+ if (!this.onShowColumn(name)) {
+ return;
+ }
+ }
+ this._reloadColspec([...this.columns, name]);
+ }
+
+ /**
+ * Reflects changes to the columns of an issue list to the URL, through
+ * frontend routing.
+ *
+ * @param {Array} newColumns the new colspec to set in the URL.
+ */
+ _reloadColspec(newColumns) {
+ this._updateQueryParams({colspec: newColumns.join(' ')});
+ }
+
+ /**
+ * Navigates to the same URL as the current page, but with query
+ * params updated.
+ *
+ * @param {Object} newParams keys and values of the queryParams
+ * Object to be updated.
+ */
+ _updateQueryParams(newParams) {
+ const params = {...this._queryParams, ...newParams};
+ this._page(`${this._baseUrl()}?${qs.stringify(params)}`);
+ }
+
+ /**
+ * Get the current URL of the page, without query params. Useful for
+ * test stubbing.
+ *
+ * @return {string} the URL of the list page, without params.
+ */
+ _baseUrl() {
+ return window.location.pathname;
+ }
+}
+
+customElements.define('mr-show-columns-dropdown', MrShowColumnsDropdown);
diff --git a/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js
new file mode 100644
index 0000000..495ffe2
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js
@@ -0,0 +1,209 @@
+// 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 {MrShowColumnsDropdown} from './mr-show-columns-dropdown.js';
+
+/** @type {MrShowColumnsDropdown} */
+let element;
+
+describe('mr-show-columns-dropdown', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-show-columns-dropdown');
+ document.body.appendChild(element);
+
+ sinon.stub(element, '_baseUrl').returns('/p/chromium/issues/list');
+ sinon.stub(element, '_page');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrShowColumnsDropdown);
+ });
+
+ it('displaying columns (spa)', async () => {
+ element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+ element.columns = ['ID'];
+ element.issues = [
+ {approvalValues: [{fieldRef: {fieldName: 'Approval-Name'}}]},
+ {fieldValues: [
+ {phaseRef: {phaseName: 'Phase'}, fieldRef: {fieldName: 'Field-Name'}},
+ {fieldRef: {fieldName: 'Field-Name'}},
+ ]},
+ {labelRefs: [{label: 'Label-Name'}]},
+ ];
+
+ await element.updateComplete;
+
+ const actual =
+ element.items.map((item) => ({icon: item.icon, text: item.text}));
+ const expected = [
+ {icon: 'check', text: 'ID'},
+ {icon: '', text: 'AllLabels'},
+ {icon: '', text: 'Approval-Name'},
+ {icon: '', text: 'Approval-Name-Approver'},
+ {icon: '', text: 'Field-Name'},
+ {icon: '', text: 'Label'},
+ {icon: '', text: 'Phase.Field-Name'},
+ {icon: '', text: 'Summary'},
+ ];
+ assert.deepEqual(actual, expected);
+ });
+
+ describe('displaying columns (ezt)', () => {
+ it('sorts default column options', async () => {
+ element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+ element.columns = [];
+ element._labelPrefixFields = [];
+
+ // Re-compute menu items on update.
+ await element.updateComplete;
+ const options = element.items;
+
+ assert.equal(options.length, 3);
+
+ assert.equal(options[0].text.trim(), 'AllLabels');
+ assert.equal(options[0].icon, '');
+
+ assert.equal(options[1].text.trim(), 'ID');
+ assert.equal(options[1].icon, '');
+
+ assert.equal(options[2].text.trim(), 'Summary');
+ assert.equal(options[2].icon, '');
+ });
+
+ it('sorts selected columns above unselected columns', async () => {
+ element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+ element.columns = ['ID'];
+ element._labelPrefixFields = [];
+
+ // Re-compute menu items on update.
+ await element.updateComplete;
+ const options = element.items;
+
+ assert.equal(options.length, 3);
+
+ assert.equal(options[0].text.trim(), 'ID');
+ assert.equal(options[0].icon, 'check');
+
+ assert.equal(options[1].text.trim(), 'AllLabels');
+ assert.equal(options[1].icon, '');
+
+ assert.equal(options[2].text.trim(), 'Summary');
+ assert.equal(options[2].icon, '');
+ });
+
+ it('sorts field defs and label prefix column options', async () => {
+ element.defaultFields = ['ID', 'Summary'];
+ element.columns = [];
+ element._fieldDefs = [
+ {fieldRef: {fieldName: 'HelloWorld'}},
+ {fieldRef: {fieldName: 'TestField'}},
+ ];
+
+ element._labelPrefixFields = ['Milestone', 'Priority'];
+
+ // Re-compute menu items on update.
+ await element.updateComplete;
+ const options = element.items;
+
+ assert.equal(options.length, 6);
+ assert.equal(options[0].text.trim(), 'HelloWorld');
+ assert.equal(options[0].icon, '');
+
+ assert.equal(options[1].text.trim(), 'ID');
+ assert.equal(options[1].icon, '');
+
+ assert.equal(options[2].text.trim(), 'Milestone');
+ assert.equal(options[2].icon, '');
+
+ assert.equal(options[3].text.trim(), 'Priority');
+ assert.equal(options[3].icon, '');
+
+ assert.equal(options[4].text.trim(), 'Summary');
+ assert.equal(options[4].icon, '');
+
+ assert.equal(options[5].text.trim(), 'TestField');
+ assert.equal(options[5].icon, '');
+ });
+
+ it('add approver fields for approval type fields', async () => {
+ element.defaultFields = [];
+ element.columns = [];
+ element._fieldDefs = [
+ {fieldRef: {fieldName: 'HelloWorld', type: 'APPROVAL_TYPE'}},
+ ];
+ element._labelPrefixFields = [];
+
+ // Re-compute menu items on update.
+ await element.updateComplete;
+ const options = element.items;
+
+ assert.equal(options.length, 2);
+ assert.equal(options[0].text.trim(), 'HelloWorld');
+ assert.equal(options[0].icon, '');
+
+ assert.equal(options[1].text.trim(), 'HelloWorld-Approver');
+ assert.equal(options[1].icon, '');
+ });
+
+ it('phase field columns are correctly named', async () => {
+ element.defaultFields = [];
+ element.columns = [];
+ element._fieldDefs = [
+ {fieldRef: {fieldName: 'Number', type: 'INT_TYPE'}, isPhaseField: true},
+ {fieldRef: {fieldName: 'Speak', type: 'STR_TYPE'}, isPhaseField: true},
+ ];
+ element._labelPrefixFields = [];
+ element.phaseNames = ['cow', 'chicken'];
+
+ // Re-compute menu items on update.
+ await element.updateComplete;
+ const options = element.items;
+
+ assert.equal(options.length, 4);
+ assert.equal(options[0].text.trim(), 'chicken.Number');
+ assert.equal(options[0].icon, '');
+
+ assert.equal(options[1].text.trim(), 'chicken.Speak');
+ assert.equal(options[1].icon, '');
+
+ assert.equal(options[2].text.trim(), 'cow.Number');
+ assert.equal(options[2].icon, '');
+
+ assert.equal(options[3].text.trim(), 'cow.Speak');
+ assert.equal(options[3].icon, '');
+ });
+ });
+
+ describe('modifying columns', () => {
+ it('clicking unset column adds a column', async () => {
+ element.columns = ['ID', 'Summary'];
+ element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+ element.queryParams = {};
+
+ await element.updateComplete;
+ element.clickItem(2);
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?colspec=ID%20Summary%20AllLabels');
+ });
+
+ it('clicking set column removes a column', async () => {
+ element.columns = ['ID', 'Summary'];
+ element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+ element.queryParams = {};
+
+ await element.updateComplete;
+ element.clickItem(0);
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?colspec=Summary');
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
new file mode 100644
index 0000000..5a3e42c
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
@@ -0,0 +1,59 @@
+// Copyright 2020 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/chops/chops-timestamp/chops-timestamp.js';
+import {determineSloStatus} from './slo-rules.js';
+
+/** @typedef {import('./slo-rules.js').SloStatus} SloStatus */
+
+/**
+ * `<mr-issue-slo>`
+ *
+ * A widget for showing the given issue's SLO status.
+ */
+export class MrIssueSlo extends LitElement {
+ /** @override */
+ static get styles() {
+ return css``;
+ }
+
+ /** @override */
+ render() {
+ const sloStatus = this._determineSloStatus();
+ if (!sloStatus) {
+ return html`N/A`;
+ }
+ if (!sloStatus.target) {
+ return html`Done`;
+ }
+ return html`
+ <chops-timestamp .timestamp=${sloStatus.target} short></chops-timestamp>`;
+ }
+
+ /**
+ * Wrapper around slo-rules.js determineSloStatus to allow tests to override
+ * the return value.
+ * @private
+ * @return {SloStatus}
+ */
+ _determineSloStatus() {
+ return this.issue ? determineSloStatus(this.issue) : null;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issue: {type: Object},
+ };
+ }
+ /** @override */
+ constructor() {
+ super();
+ /** @type {Issue} */
+ this.issue;
+ }
+}
+customElements.define('mr-issue-slo', MrIssueSlo);
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js
new file mode 100644
index 0000000..28d23eb
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js
@@ -0,0 +1,54 @@
+// 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 {MrIssueSlo} from './mr-issue-slo.js';
+
+
+let element;
+
+describe('mr-issue-slo', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-slo');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueSlo);
+ });
+
+ it('handles ineligible issues', async () => {
+ element._determineSloStatus = () => {
+ return null;
+ };
+ element.issue = {};
+ await element.updateComplete;
+ assert.equal(element.shadowRoot.textContent, 'N/A');
+ });
+
+ it('handles issues that have completed the SLO criteria', async () => {
+ element._determineSloStatus = () => {
+ return {target: null};
+ };
+ element.issue = {};
+ await element.updateComplete;
+ assert.equal(element.shadowRoot.textContent, 'Done');
+ });
+
+ it('handles issues that have not completed the SLO criteria', async () => {
+ element._determineSloStatus = () => {
+ return {target: 1234};
+ };
+ element.issue = {};
+ await element.updateComplete;
+ const timestampElement =
+ element.shadowRoot.querySelector('chops-timestamp');
+
+ assert.equal(timestampElement.timestamp, 1234);
+ });
+});
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.js b/static_src/elements/framework/mr-issue-slo/slo-rules.js
new file mode 100644
index 0000000..e351ae0
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.js
@@ -0,0 +1,195 @@
+// Copyright 2020 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 Determining Issues' statuses relative to SLO rules.
+ *
+ * See go/monorail-slo-v0 for more info.
+ */
+
+/**
+ * A rule determining the compliance of an issue with regard to an SLO.
+ * @typedef {Object} SloRule
+ * @property {function(Issue): SloStatus} statusFunction
+ */
+
+/**
+ * Potential statuses of an issue relative to an SLO's completion criteria.
+ * @enum {string}
+ */
+export const SloCompletionStatus = {
+ /** The completion criteria for the SloRule have not been satisfied. */
+ INCOMPLETE: 'INCOMPLETE',
+ /** The completion criteria for the SloRule have been satisfied. */
+ COMPLETE: 'COMPLETE',
+};
+
+/**
+ * The status of an issue with regard to an SloRule.
+ * @typedef {Object} SloStatus
+ * @property {SloRule} rule The rule that generated this status.
+ * @property {Date} target The time the Issue must move to completion, or null
+ * if the issue has already moved to completion.
+ * @property {SloCompletionStatus} completion Issue's completion status.
+ */
+
+/**
+ * Chrome OS Software's SLO for issue closure (go/chromeos-software-bug-slos).
+ *
+ * Implementation based on the queries defined in Sheriffbot
+ * https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py
+ *
+ * @const {SloRule}
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO = {
+ statusFunction: (issue) => {
+ if (!_isCrosClosureEligible(issue)) {
+ return null;
+ }
+
+ const pri = getPriFromIssue(issue);
+ const daysToClose = _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY[pri];
+
+ if (!daysToClose) {
+ // No applicable SLO found issues with this priority.
+ return null;
+ }
+ // Return a complete status for closed issues.
+ if (issue.statusRef && !issue.statusRef.meansOpen) {
+ return {
+ rule: _CROS_CLOSURE_SLO,
+ target: null,
+ completion: SloCompletionStatus.COMPLETE};
+ }
+
+ // Set the target based on the opening and the daysToClose.
+ const target = new Date(issue.openedTimestamp * 1000);
+ target.setDate(target.getDate() + daysToClose);
+ return {
+ rule: _CROS_CLOSURE_SLO,
+ target: target,
+ completion: SloCompletionStatus.INCOMPLETE};
+ },
+};
+
+/**
+ * @param {Issue} issue
+ * @return {string?} the pri's value, if found.
+ */
+const getPriFromIssue = (issue) => {
+ for (const fv of issue.fieldValues) {
+ if (fv.fieldRef.fieldName === 'Pri') {
+ return fv.value;
+ }
+ }
+};
+
+/**
+ * The number of days (since the issue was opened) allowed for it to be fixed.
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY = Object.freeze({
+ '1': 42,
+});
+
+// https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py#97
+const CROS_ELIGIBLE_COMPONENT_PATHS = new Set([
+ 'OS>Systems>CrashReporting',
+ 'OS>Systems>Displays',
+ 'OS>Systems>Feedback',
+ 'OS>Systems>HaTS',
+ 'OS>Systems>Input',
+ 'OS>Systems>Input>Keyboard',
+ 'OS>Systems>Input>Mouse',
+ 'OS>Systems>Input>Shortcuts',
+ 'OS>Systems>Input>Touch',
+ 'OS>Systems>Metrics',
+ 'OS>Systems>Multidevice',
+ 'OS>Systems>Multidevice>Messages',
+ 'OS>Systems>Multidevice>SmartLock',
+ 'OS>Systems>Multidevice>Tethering',
+ 'OS>Systems>Network>Bluetooth',
+ 'OS>Systems>Network>Cellular',
+ 'OS>Systems>Network>VPN',
+ 'OS>Systems>Network>WiFi',
+ 'OS>Systems>Printing',
+ 'OS>Systems>Settings',
+ 'OS>Systems>Spellcheck',
+ 'OS>Systems>Update',
+ 'OS>Systems>Wallpaper',
+ 'OS>Systems>WirelessCharging',
+ 'Platform>Apps>Feedback',
+ 'UI>Shell>Networking',
+]);
+
+/**
+ * Determines if an issue is eligible for _CROS_CLOSURE_SLO.
+ * @param {Issue} issue
+ * @return {boolean}
+ * @private Only visible for testing.
+ */
+export const _isCrosClosureEligible = (issue) => {
+ // If at least one component applies, continue.
+ const hasEligibleComponent = issue.componentRefs.some(
+ (component) => CROS_ELIGIBLE_COMPONENT_PATHS.has(component.path));
+ if (!hasEligibleComponent) {
+ return false;
+ }
+
+ let priority = null;
+ let hasMilestone = false;
+ for (const fv of issue.fieldValues) {
+ if (fv.fieldRef.fieldName === 'Type') {
+ // These types don't apply.
+ if (fv.value === 'Feature' || fv.value === 'FLT-Launch' ||
+ fv.value === 'Postmortem-Followup' || fv.value === 'Design-Review') {
+ return false;
+ }
+ }
+ if (fv.fieldRef.fieldName === 'Pri') {
+ priority = fv.value;
+ }
+ if (fv.fieldRef.fieldName === 'M') {
+ hasMilestone = true;
+ }
+ }
+ // P1 issues with milestones don't apply.
+ if (priority === '1' && hasMilestone) {
+ return false;
+ }
+ // Issues with the ChromeOS_No_SLO label don't apply.
+ for (const labelRef of issue.labelRefs) {
+ if (labelRef.label === 'ChromeOS_No_SLO') {
+ return false;
+ }
+ }
+ return true;
+};
+
+/**
+ * Active SLO Rules.
+ * @const {Array<SloRule>}
+ */
+const SLO_RULES = [_CROS_CLOSURE_SLO];
+
+/**
+ * Determines the SloStatus for the given issue.
+ * @param {Issue} issue The issue to check.
+ * @return {SloStatus} The status of the issue, or null if no rules apply.
+ */
+export const determineSloStatus = (issue) => {
+ try {
+ for (const rule of SLO_RULES) {
+ const status = rule.statusFunction(issue);
+ if (status) {
+ return status;
+ }
+ }
+ } catch (error) {
+ // Don't bubble up any errors in SLO_RULES functions, which might sometimes
+ // be written/updated by client teams.
+ }
+ return null;
+};
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.test.js b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
new file mode 100644
index 0000000..a48e5e2
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
@@ -0,0 +1,152 @@
+// Copyright 2020 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 {_CROS_CLOSURE_SLO, _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY,
+ _isCrosClosureEligible, SloCompletionStatus, determineSloStatus}
+ from './slo-rules.js';
+
+const P1_FIELD_VALUE = Object.freeze({
+ fieldRef: {
+ fieldId: 1,
+ fieldName: 'Pri',
+ type: 'ENUM_TYPE',
+ },
+ value: '1'});
+
+// TODO(crbug.com/monorail/7843): Separate testing of determineSloStatus from
+// testing of specific SLO Rules. Add testing for a rule that throws an error.
+describe('determineSloStatus', () => {
+ it('returns null for ineligible issues', () => {
+ const ineligibleIssue = {
+ componentRefs: [{path: 'Some>Other>Component'}],
+ fieldValues: [P1_FIELD_VALUE],
+ labelRefs: [],
+ localId: 1,
+ projectName: 'x',
+ };
+ assert.isNull(determineSloStatus(ineligibleIssue));
+ });
+
+ it('returns null for eligible issues without defined priority', () => {
+ const ineligibleIssue = {
+ componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+ fieldValues: [],
+ labelRefs: [],
+ localId: 1,
+ projectName: 'x',
+ };
+ assert.isNull(determineSloStatus(ineligibleIssue));
+ });
+
+ it('returns SloStatus with target for incomplete eligible issues', () => {
+ const openedTimestamp = 1412362587;
+ const eligibleIssue = {
+ componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+ fieldValues: [P1_FIELD_VALUE],
+ labelRefs: [],
+ localId: 1,
+ openedTimestamp: openedTimestamp,
+ projectName: 'x',
+ };
+ const status = determineSloStatus(eligibleIssue);
+
+ const expectedTarget = new Date(openedTimestamp * 1000);
+ expectedTarget.setDate(
+ expectedTarget.getDate() + _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY['1']);
+
+ assert.equal(status.target.valueOf(), expectedTarget.valueOf());
+ assert.equal(status.completion, SloCompletionStatus.INCOMPLETE);
+ assert.equal(status.rule, _CROS_CLOSURE_SLO);
+ });
+
+ it('returns SloStatus without target for complete eligible issues', () => {
+ const eligibleIssue = {
+ componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+ fieldValues: [P1_FIELD_VALUE],
+ labelRefs: [],
+ localId: 1,
+ projectName: 'x',
+ statusRef: {status: 'Closed', meansOpen: false},
+ };
+ const status = determineSloStatus(eligibleIssue);
+ assert.isNull(status.target);
+ assert.equal(status.completion, SloCompletionStatus.COMPLETE);
+ assert.equal(status.rule, _CROS_CLOSURE_SLO);
+ });
+});
+
+describe('_isCrosClosureEligible', () => {
+ let crosIssue;
+ beforeEach(() => {
+ crosIssue = {
+ componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+ fieldValues: [],
+ labelRefs: [],
+ localId: 1,
+ projectName: 'x',
+ };
+ });
+
+ it('returns true when eligible', () => {
+ assert.isTrue(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns true if at least one eligible component', () => {
+ crosIssue.componentRefs.push({path: 'Some>Other>Component'});
+ assert.isTrue(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns false for issues in wrong component', () => {
+ crosIssue.componentRefs = [{path: 'Some>Other>Component'}];
+ assert.isFalse(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns false for Feature', () => {
+ crosIssue.fieldValues.push(
+ {fieldRef: {fieldName: 'Type'}, value: 'Feature'});
+ assert.isFalse(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns false for FLT-Launch', () => {
+ crosIssue.fieldValues.push(
+ {fieldRef: {fieldName: 'Type'}, value: 'FLT-Launch'});
+ assert.isFalse(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns false for Postmortem-Followup', () => {
+ crosIssue.fieldValues.push(
+ {fieldRef: {fieldName: 'Type'}, value: 'Postmortem-Followup'});
+ assert.isFalse(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns false for Design-Review', () => {
+ crosIssue.fieldValues.push(
+ {fieldRef: {fieldName: 'Type'}, value: 'Design-Review'});
+ assert.isFalse(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns true for other types', () => {
+ crosIssue.fieldValues.push(
+ {fieldRef: {fieldName: 'type'}, value: 'Any-Other-Type'});
+ assert.isTrue(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns false for p1 with milestone', () => {
+ crosIssue.fieldValues.push(P1_FIELD_VALUE);
+ crosIssue.fieldValues.push({fieldRef: {fieldName: 'M'}, value: 'any'});
+ assert.isFalse(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns true for p1 without milestone', () => {
+ crosIssue.fieldValues.push(P1_FIELD_VALUE);
+ crosIssue.fieldValues.push({fieldRef: {fieldName: 'Other'}, value: 'any'});
+ assert.isTrue(_isCrosClosureEligible(crosIssue));
+ });
+
+ it('returns false for ChromeOS_No_SLO label', () => {
+ crosIssue.labelRefs.push({label: 'ChromeOS_No_SLO'});
+ assert.isFalse(_isCrosClosureEligible(crosIssue));
+ });
+});
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
new file mode 100644
index 0000000..9e932d6
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
@@ -0,0 +1,421 @@
+// 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 page from 'page';
+import qs from 'qs';
+import Mousetrap from 'mousetrap';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+
+
+const SHORTCUT_DOC_GROUPS = [
+ {
+ title: 'Issue list',
+ keyDocs: [
+ {
+ keys: ['k', 'j'],
+ tip: 'up/down in the list',
+ },
+ {
+ keys: ['o', 'Enter'],
+ tip: 'open the current issue',
+ },
+ {
+ keys: ['Shift-O'],
+ tip: 'open issue in new tab',
+ },
+ {
+ keys: ['x'],
+ tip: 'select the current issue',
+ },
+ ],
+ },
+ {
+ title: 'Issue details',
+ keyDocs: [
+ {
+ keys: ['k', 'j'],
+ tip: 'prev/next issue in list',
+ },
+ {
+ keys: ['u'],
+ tip: 'up to issue list',
+ },
+ {
+ keys: ['r'],
+ tip: 'reply to current issue',
+ },
+ {
+ keys: ['Ctrl+Enter', '\u2318+Enter'],
+ tip: 'save issue reply (submit issue on issue filing page)',
+ },
+ ],
+ },
+ {
+ title: 'Anywhere',
+ keyDocs: [
+ {
+ keys: ['/'],
+ tip: 'focus on the issue search field',
+ },
+ {
+ keys: ['c'],
+ tip: 'compose a new issue',
+ },
+ {
+ keys: ['s'],
+ tip: 'star the current issue',
+ },
+ {
+ keys: ['?'],
+ tip: 'show this help dialog',
+ },
+ ],
+ },
+];
+
+/**
+ * `<mr-keystrokes>`
+ *
+ * Adds keybindings for Monorail, including a dialog for showing keystrokes.
+ * @extends {LitElement}
+ */
+export class MrKeystrokes extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return css`
+ h2 {
+ margin-top: 0;
+ display: flex;
+ justify-content: space-between;
+ font-weight: normal;
+ border-bottom: 2px solid white;
+ font-size: var(--chops-large-font-size);
+ padding-bottom: 0.5em;
+ }
+ .close-button {
+ border: 0;
+ background: 0;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+ .keyboard-help {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-around;
+ flex-direction: row;
+ border-bottom: 2px solid white;
+ flex-wrap: wrap;
+ }
+ .keyboard-help-section {
+ width: 32%;
+ display: grid;
+ grid-template-columns: 40% 60%;
+ padding-bottom: 1em;
+ grid-gap: 4px;
+ min-width: 300px;
+ }
+ .help-title {
+ font-weight: bold;
+ }
+ .key-shortcut {
+ text-align: right;
+ padding-right: 8px;
+ font-weight: bold;
+ margin: 2px;
+ }
+ kbd {
+ background: var(--chops-gray-200);
+ padding: 2px 8px;
+ border-radius: 2px;
+ min-width: 28px;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <chops-dialog ?opened=${this._opened}>
+ <h2>
+ Issue tracker keyboard shortcuts
+ <button class="close-button" @click=${this._closeDialog}>
+ Close
+ </button>
+ </h2>
+ <div class="keyboard-help">
+ ${this._shortcutDocGroups.map((group) => html`
+ <div class="keyboard-help-section">
+ <span></span><span class="help-title">${group.title}</span>
+ ${group.keyDocs.map((keyDoc) => html`
+ <span class="key-shortcut">
+ ${keyDoc.keys.map((key, i) => html`
+ <kbd>${key}</kbd>
+ <span
+ class="key-separator"
+ ?hidden=${i === keyDoc.keys.length - 1}
+ > / </span>
+ `)}:
+ </span>
+ <span class="key-tip">${keyDoc.tip}</span>
+ `)}
+ </div>
+ `)}
+ </div>
+ <p>
+ Note: Only signed in users can star issues or add comments, and
+ only project members can select issues for bulk edits.
+ </p>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issueEntryUrl: {type: String},
+ issueId: {type: Number},
+ _projectName: {type: String},
+ queryParams: {type: Object},
+ _fetchingIsStarred: {type: Boolean},
+ _isStarred: {type: Boolean},
+ _issuePermissions: {type: Array},
+ _opened: {type: Boolean},
+ _shortcutDocGroups: {type: Array},
+ _starringIssues: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this._shortcutDocGroups = SHORTCUT_DOC_GROUPS;
+ this._opened = false;
+ this._starringIssues = new Map();
+ this._projectName = undefined;
+ this._issuePermissions = [];
+ this.issueId = undefined;
+ this.queryParams = undefined;
+ this.issueEntryUrl = undefined;
+
+ this._page = page;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._projectName = projectV0.viewedProjectName(state);
+ this._issuePermissions = issueV0.permissions(state);
+
+ const starredIssues = issueV0.starredIssues(state);
+ this._isStarred = starredIssues.has(issueRefToString(this._issueRef));
+ this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+ this._starringIssues = issueV0.starringIssues(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('_projectName') ||
+ changedProperties.has('issueEntryUrl')) {
+ this._bindProjectKeys(this._projectName, this.issueEntryUrl);
+ }
+ if (changedProperties.has('_projectName') ||
+ changedProperties.has('issueId') ||
+ changedProperties.has('_issuePermissions') ||
+ changedProperties.has('queryParams')) {
+ this._bindIssueDetailKeys(this._projectName, this.issueId,
+ this._issuePermissions, this.queryParams);
+ }
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._unbindProjectKeys();
+ this._unbindIssueDetailKeys();
+ }
+
+ /** @private */
+ get _isStarring() {
+ const requestKey = issueRefToString(this._issueRef);
+ if (this._starringIssues.has(requestKey)) {
+ return this._starringIssues.get(requestKey).requesting;
+ }
+ return false;
+ }
+
+ /** @private */
+ get _issueRef() {
+ return {
+ projectName: this._projectName,
+ localId: this.issueId,
+ };
+ }
+
+ /** @private */
+ _toggleDialog() {
+ this._opened = !this._opened;
+ }
+
+ /** @private */
+ _openDialog() {
+ this._opened = true;
+ }
+
+ /** @private */
+ _closeDialog() {
+ this._opened = false;
+ }
+
+ /**
+ * @param {string} projectName
+ * @param {string} issueEntryUrl
+ * @fires CustomEvent#focus-search
+ * @private
+ */
+ _bindProjectKeys(projectName, issueEntryUrl) {
+ this._unbindProjectKeys();
+
+ if (!projectName) return;
+
+ issueEntryUrl = issueEntryUrl || `/p/${projectName}/issues/entry`;
+
+ Mousetrap.bind('/', (e) => {
+ e.preventDefault();
+ // Focus search.
+ this.dispatchEvent(new CustomEvent('focus-search',
+ {composed: true, bubbles: true}));
+ });
+
+ Mousetrap.bind('?', () => {
+ // Toggle key help.
+ this._toggleDialog();
+ });
+
+ Mousetrap.bind('esc', () => {
+ // Close key help dialog if open.
+ this._closeDialog();
+ });
+
+ Mousetrap.bind('c', () => this._page(issueEntryUrl));
+ }
+
+ /** @private */
+ _unbindProjectKeys() {
+ Mousetrap.unbind('/');
+ Mousetrap.unbind('?');
+ Mousetrap.unbind('esc');
+ Mousetrap.unbind('c');
+ }
+
+ /**
+ * @param {string} projectName
+ * @param {string} issueId
+ * @param {Array<string>} issuePermissions
+ * @param {Object} queryParams
+ * @private
+ */
+ _bindIssueDetailKeys(projectName, issueId, issuePermissions, queryParams) {
+ this._unbindIssueDetailKeys();
+
+ if (!projectName || !issueId) return;
+
+ const projectHomeUrl = `/p/${projectName}`;
+
+ const queryString = qs.stringify(queryParams);
+
+ // TODO(zhangtiff): Update these links when mr-flipper's async request
+ // finishes.
+ const prevUrl = `${projectHomeUrl}/issues/detail/previous?${queryString}`;
+ const nextUrl = `${projectHomeUrl}/issues/detail/next?${queryString}`;
+ const canComment = issuePermissions.includes('addissuecomment');
+ const canStar = issuePermissions.includes('setstar');
+
+ // Previous issue in list.
+ Mousetrap.bind('k', () => this._page(prevUrl));
+
+ // Next issue in list.
+ Mousetrap.bind('j', () => this._page(nextUrl));
+
+ // Back to list.
+ Mousetrap.bind('u', () => this._backToList());
+
+ if (canComment) {
+ // Navigate to the form to make changes.
+ Mousetrap.bind('r', () => this._jumpToEditForm());
+ }
+
+ if (canStar) {
+ Mousetrap.bind('s', () => this._starIssue());
+ }
+ }
+
+ /**
+ * Navigates back to the issue list page.
+ * @private
+ */
+ _backToList() {
+ const params = {...this.queryParams,
+ cursor: issueRefToString(this._issueRef)};
+ const queryString = qs.stringify(params);
+ if (params['hotlist_id']) {
+ // Because hotlist URLs require a server look up to be built from a
+ // hotlist ID, we have to route the request through an extra endpoint
+ // that redirects to the appropriate hotlist.
+ const listUrl = `/p/${this._projectName}/issues/detail/list?${
+ queryString}`;
+ this._page(listUrl);
+
+ // TODO(crbug.com/monorail/6341): Switch to using the new hotlist URL once
+ // hotlists have migrated.
+ // this._page(`/hotlists/${params['hotlist_id']}`);
+ } else {
+ delete params.id;
+ const listUrl = `/p/${this._projectName}/issues/list?${queryString}`;
+ this._page(listUrl);
+ }
+ }
+
+ /**
+ * Scrolls the user to the issue editing form when they press
+ * the 'r' key.
+ * @private
+ */
+ _jumpToEditForm() {
+ // Force a hash change even the hash is already makechanges.
+ if (window.location.hash.toLowerCase() === '#makechanges') {
+ window.location.hash = ' ';
+ }
+ window.location.hash = '#makechanges';
+ }
+
+ /**
+ * Stars the current issue the user is viewing on the issue detail page.
+ * @private
+ */
+ _starIssue() {
+ if (!this._fetchingIsStarred && !this._isStarring) {
+ const newIsStarred = !this._isStarred;
+
+ store.dispatch(issueV0.star(this._issueRef, newIsStarred));
+ }
+ }
+
+
+ /** @private */
+ _unbindIssueDetailKeys() {
+ Mousetrap.unbind('k');
+ Mousetrap.unbind('j');
+ Mousetrap.unbind('u');
+ Mousetrap.unbind('r');
+ Mousetrap.unbind('s');
+ }
+}
+
+customElements.define('mr-keystrokes', MrKeystrokes);
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
new file mode 100644
index 0000000..0d7468f
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
@@ -0,0 +1,194 @@
+// 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 {MrKeystrokes} from './mr-keystrokes.js';
+import Mousetrap from 'mousetrap';
+
+import {issueRefToString} from 'shared/convertersV0.js';
+
+/** @type {MrKeystrokes} */
+let element;
+
+describe('mr-keystrokes', () => {
+ beforeEach(() => {
+ element = /** @type {MrKeystrokes} */ (
+ document.createElement('mr-keystrokes'));
+ document.body.appendChild(element);
+
+ element._projectName = 'proj';
+ element.issueId = 11;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrKeystrokes);
+ });
+
+ it('tracks if the issue is currently starring', async () => {
+ await element.updateComplete;
+ assert.isFalse(element._isStarring);
+
+ const issueRefStr = issueRefToString(element._issueRef);
+ element._starringIssues.set(issueRefStr, {requesting: true});
+ assert.isTrue(element._isStarring);
+ });
+
+ it('? and esc open and close dialog', async () => {
+ await element.updateComplete;
+ assert.isFalse(element._opened);
+
+ Mousetrap.trigger('?');
+
+ await element.updateComplete;
+ assert.isTrue(element._opened);
+
+ Mousetrap.trigger('esc');
+
+ await element.updateComplete;
+ assert.isFalse(element._opened);
+ });
+
+ describe('issue detail keys', () => {
+ beforeEach(() => {
+ sinon.stub(element, '_page');
+ sinon.stub(element, '_jumpToEditForm');
+ sinon.stub(element, '_starIssue');
+ });
+
+ it('not bound when _projectName not set', async () => {
+ element._projectName = '';
+ element.issueId = 1;
+
+ await element.updateComplete;
+
+ // Navigation hot keys.
+ Mousetrap.trigger('k');
+ Mousetrap.trigger('j');
+ Mousetrap.trigger('u');
+ sinon.assert.notCalled(element._page);
+
+ // Jump to edit form hot key.
+ Mousetrap.trigger('r');
+ sinon.assert.notCalled(element._jumpToEditForm);
+
+ // Star issue hotkey.
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('not bound when issueId not set', async () => {
+ element._projectName = 'proj';
+ element.issueId = 0;
+
+ await element.updateComplete;
+
+ // Navigation hot keys.
+ Mousetrap.trigger('k');
+ Mousetrap.trigger('j');
+ Mousetrap.trigger('u');
+ sinon.assert.notCalled(element._page);
+
+ // Jump to edit form hot key.
+ Mousetrap.trigger('r');
+ sinon.assert.notCalled(element._jumpToEditForm);
+
+ // Star issue hotkey.
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('binds j and k navigation hot keys', async () => {
+ element.queryParams = {q: 'something'};
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('k');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/detail/previous?q=something');
+
+ Mousetrap.trigger('j');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/detail/next?q=something');
+
+ Mousetrap.trigger('u');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/list?q=something&cursor=proj%3A11');
+ });
+
+ it('u key navigates back to issue list wth cursor set', async () => {
+ element.queryParams = {q: 'something'};
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('u');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/list?q=something&cursor=proj%3A11');
+ });
+
+ it('u key navigates back to hotlist when hotlist_id set', async () => {
+ element.queryParams = {hotlist_id: 1234};
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('u');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/detail/list?hotlist_id=1234&cursor=proj%3A11');
+ });
+
+ it('does not star when user does not have permission', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = [];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('does star when user has permission', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = ['setstar'];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('s');
+ sinon.assert.calledOnce(element._starIssue);
+ });
+
+ it('does not star when user does not have permission', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = [];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('does not jump to edit form when user cannot comment', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = [];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('r');
+ sinon.assert.notCalled(element._jumpToEditForm);
+ });
+
+ it('does jump to edit form when user can comment', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = ['addissuecomment'];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('r');
+ sinon.assert.calledOnce(element._jumpToEditForm);
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js
new file mode 100644
index 0000000..a5f9d7a
--- /dev/null
+++ b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js
@@ -0,0 +1,94 @@
+// 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} from 'lit-element';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-toggle/chops-toggle.js';
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * `<mr-pref-toggle>`
+ *
+ * Toggle button for any user pref, including code font and
+ * rendering markdown. For our purposes, pressing it causes
+ * issue description and comment text to switch either to
+ * monospace font or to render in markdown and the setting
+ * is saved in the user's preferences.
+ */
+export class MrPrefToggle extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <chops-toggle
+ ?checked=${this._checked}
+ ?disabled=${this._prefsInFlight}
+ @checked-change=${this._togglePref}
+ title=${this.title}
+ >${this.label}</chops-toggle>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ prefs: {type: Object},
+ userDisplayName: {type: String},
+ initialValue: {type: Boolean},
+ _prefsInFlight: {type: Boolean},
+ label: {type: String},
+ title: {type: String},
+ prefName: {type: String},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.prefs = userV0.prefs(state);
+ this._prefsInFlight = userV0.requests(state).fetchPrefs.requesting ||
+ userV0.requests(state).setPrefs.requesting;
+ this._projectName = projectV0.viewedProjectName(state);
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.initialValue = false;
+ this.userDisplayName = '';
+ this.label = '';
+ this.title = '';
+ this.prefName = '';
+ this._projectName = '';
+ }
+
+ // Used by the legacy EZT page to interact with Redux.
+ fetchPrefs() {
+ store.dispatch(userV0.fetchPrefs());
+ }
+
+ get _checked() {
+ const {prefs, initialValue} = this;
+ if (prefs && prefs.has(this.prefName)) return prefs.get(this.prefName);
+ return initialValue;
+ }
+
+ /**
+ * Toggles the code font in response to the user activating the button.
+ * @param {Event} e
+ * @fires CustomEvent#font-toggle
+ * @private
+ */
+ _togglePref(e) {
+ const checked = e.detail.checked;
+ this.dispatchEvent(new CustomEvent('font-toggle', {detail: {checked}}));
+
+ const newPrefs = [{name: this.prefName, value: '' + checked}];
+ store.dispatch(userV0.setPrefs(newPrefs, !!this.userDisplayName));
+
+ logEvent('mr-pref-toggle', `${this.prefName}: ${checked}`, this._projectName);
+ }
+}
+customElements.define('mr-pref-toggle', MrPrefToggle);
diff --git a/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js
new file mode 100644
index 0000000..b6dbb41
--- /dev/null
+++ b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js
@@ -0,0 +1,86 @@
+// 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 'sinon';
+import {assert} from 'chai';
+import {MrPrefToggle} from './mr-pref-toggle.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+
+describe('mr-pref-toggle', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-pref-toggle');
+ element.label = 'Code';
+ element.title = 'Code font';
+ element.prefName = 'code_font';
+ document.body.appendChild(element);
+ sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+ window.ga = sinon.stub();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrPrefToggle);
+ });
+
+ it('toggling does not save when user is not logged in', async () => {
+ element.userDisplayName = undefined;
+ element.prefs = new Map([]);
+
+ await element.updateComplete;
+
+ const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+ chopsToggle.click();
+ await element.updateComplete;
+
+ sinon.assert.notCalled(prpcClient.call);
+
+ assert.isTrue(element.prefs.get('code_font'));
+ });
+
+ it('toggling to true saves result', async () => {
+ element.userDisplayName = 'test@example.com';
+ element.prefs = new Map([['code_font', false]]);
+
+ await element.updateComplete;
+
+ const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+
+ chopsToggle.click(); // Toggle it on.
+ await element.updateComplete;
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Users',
+ 'SetUserPrefs',
+ {prefs: [{name: 'code_font', value: 'true'}]});
+
+ assert.isTrue(element.prefs.get('code_font'));
+ });
+
+ it('toggling to false saves result', async () => {
+ element.userDisplayName = 'test@example.com';
+ element.prefs = new Map([['code_font', true]]);
+
+ await element.updateComplete;
+
+ const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+
+ chopsToggle.click(); // Toggle it off.
+ await element.updateComplete;
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Users',
+ 'SetUserPrefs',
+ {prefs: [{name: 'code_font', value: 'false'}]});
+
+ assert.isFalse(element.prefs.get('code_font'));
+ });
+});
diff --git a/static_src/elements/framework/mr-site-banner/mr-site-banner.js b/static_src/elements/framework/mr-site-banner/mr-site-banner.js
new file mode 100644
index 0000000..2a98a5c
--- /dev/null
+++ b/static_src/elements/framework/mr-site-banner/mr-site-banner.js
@@ -0,0 +1,75 @@
+// 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/chops/chops-timestamp/chops-timestamp.js';
+import {connectStore} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+export class MrSiteBanner extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return css`
+ :host([hidden]) {
+ display: none;
+ }
+ :host {
+ display: block;
+ font-weight: bold;
+ color: var(--chops-field-error-color);
+ background: var(--chops-orange-50);
+ padding: 5px;
+ text-align: center;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ ${this.bannerMessage}
+ ${this.bannerTime ? html`
+ <chops-timestamp
+ .timestamp=${this.bannerTime}
+ ></chops-timestamp>
+ ` : ''}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ hidden: {
+ type: Boolean,
+ reflect: true,
+ },
+ bannerMessage: {type: String},
+ bannerTime: {type: Number},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.bannerMessage = '';
+ this.bannerTime = 0;
+ this.hidden = false;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.bannerMessage = sitewide.bannerMessage(state);
+ this.bannerTime = sitewide.bannerTime(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('bannerMessage')) {
+ this.hidden = !this.bannerMessage;
+ }
+ }
+}
+
+customElements.define('mr-site-banner', MrSiteBanner);
diff --git a/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js b/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js
new file mode 100644
index 0000000..527b942
--- /dev/null
+++ b/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js
@@ -0,0 +1,56 @@
+// 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 {FORMATTER}
+ from 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {MrSiteBanner} from './mr-site-banner.js';
+
+
+let element;
+
+describe('mr-site-banner', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-site-banner');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrSiteBanner);
+ });
+
+ it('displays a banner message', async () => {
+ element.bannerMessage = 'Message';
+ await element.updateComplete;
+ assert.equal(element.shadowRoot.textContent.trim(), 'Message');
+ assert.isNull(element.shadowRoot.querySelector('chops-timestamp'));
+ });
+
+ it('displays the banner timestamp', async () => {
+ const timestamp = 1560450600;
+
+ element.bannerMessage = 'Message';
+ element.bannerTime = timestamp;
+ await element.updateComplete;
+
+ const chopsTimestamp = element.shadowRoot.querySelector('chops-timestamp');
+
+ // The formatted date strings differ based on time zone and browser, so we
+ // can't use static strings for testing. We can't stub out the format method
+ // because it's native code and can't be modified. So just use the FORMATTER
+ // object.
+ assert.include(
+ chopsTimestamp.shadowRoot.textContent,
+ FORMATTER.format(new Date(timestamp * 1000)));
+ });
+
+ it('hides when there is no banner message', async () => {
+ await element.updateComplete;
+ assert.isTrue(element.hidden);
+ });
+});
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.js b/static_src/elements/framework/mr-star/mr-issue-star.js
new file mode 100644
index 0000000..5255820
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.js
@@ -0,0 +1,110 @@
+// Copyright 2020 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 {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import {MrStar} from './mr-star.js';
+
+
+/**
+ * `<mr-issue-star>`
+ *
+ * A button for starring an issue.
+ *
+ */
+export class MrIssueStar extends connectStore(MrStar) {
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * A reference to the issue that the star button interacts with.
+ */
+ issueRef: {type: Object},
+ /**
+ * Whether the issue is starred (used for accessing easily).
+ */
+ _starredIssues: {type: Set},
+ /**
+ * Whether the issue's star state is being fetched. This is taken from
+ * the component's parent, which is expected to handle fetching initial
+ * star state for an issue.
+ */
+ _fetchingIsStarred: {type: Boolean},
+ /**
+ * A Map of all issues currently being starred.
+ */
+ _starringIssues: {type: Object},
+ /**
+ * The currently logged in user. Required to determine if the user can
+ * star.
+ */
+ _currentUserName: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /**
+ * @type {IssueRef}
+ */
+ this.issueRef = {};
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._currentUserName = users.currentUserName(state);
+
+ // TODO(crbug.com/monorail/7374): Remove references to issueV0 in
+ // <mr-star>.
+ this._starringIssues = issueV0.starringIssues(state);
+ this._starredIssues = issueV0.starredIssues(state);
+ this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+ }
+
+ /** @override */
+ get type() {
+ return 'issue';
+ }
+
+ /**
+ * @return {boolean} Whether there's an in-flight star request.
+ */
+ get _isStarring() {
+ const requestKey = issueRefToString(this.issueRef);
+ if (this._starringIssues.has(requestKey)) {
+ return this._starringIssues.get(requestKey).requesting;
+ }
+ return false;
+ }
+
+ /** @override */
+ get isLoggedIn() {
+ return !!this._currentUserName;
+ }
+
+ /** @override */
+ get requesting() {
+ return this._fetchingIsStarred || this._isStarring;
+ }
+
+ /** @override */
+ get isStarred() {
+ return this._starredIssues.has(issueRefToString(this.issueRef));
+ }
+
+ /** @override */
+ star() {
+ store.dispatch(issueV0.star(this.issueRef, true));
+ }
+
+ /** @override */
+ unstar() {
+ store.dispatch(issueV0.star(this.issueRef, false));
+ }
+}
+
+customElements.define('mr-issue-star', MrIssueStar);
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.test.js b/static_src/elements/framework/mr-star/mr-issue-star.test.js
new file mode 100644
index 0000000..bb618f7
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.test.js
@@ -0,0 +1,85 @@
+// Copyright 2020 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 {MrIssueStar} from './mr-issue-star.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import sinon from 'sinon';
+
+
+let element;
+
+describe('mr-issue-star', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-star');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueStar);
+ });
+
+ it('starring logins user when user is not logged in', async () => {
+ element._currentUserName = undefined;
+ sinon.stub(element, 'login');
+
+ await element.updateComplete;
+
+ const star = element.shadowRoot.querySelector('button');
+
+ star.click();
+
+ sinon.assert.calledOnce(element.login);
+ });
+
+ it('_isStarring true only when issue ref is being starred', async () => {
+ element._starringIssues = new Map([['chromium:22', {requesting: true}]]);
+ element.issueRef = {projectName: 'chromium', localId: 5};
+
+ assert.isFalse(element._isStarring);
+
+ element.issueRef = {projectName: 'chromium', localId: 22};
+
+ assert.isTrue(element._isStarring);
+
+ element._starringIssues = new Map([['chromium:22', {requesting: false}]]);
+
+ assert.isFalse(element._isStarring);
+ });
+
+ it('starring is disabled when _isStarring true', () => {
+ element._currentUserName = 'users/1234';
+ sinon.stub(element, '_isStarring').get(() => true);
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('starring is disabled when _fetchingIsStarred true', () => {
+ element._currentUserName = 'users/1234';
+ element._fetchingIsStarred = true;
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('_starredIssues changes displayed icon', async () => {
+ element.issueRef = {projectName: 'proj', localId: 1};
+
+ element._starredIssues = new Set([issueRefToString(element.issueRef)]);
+
+ await element.updateComplete;
+
+ const star = element.shadowRoot.querySelector('button');
+ assert.equal(star.textContent.trim(), 'star');
+
+ element._starredIssues = new Set();
+
+ await element.updateComplete;
+
+ assert.equal(star.textContent.trim(), 'star_border');
+ });
+});
diff --git a/static_src/elements/framework/mr-star/mr-project-star.js b/static_src/elements/framework/mr-star/mr-project-star.js
new file mode 100644
index 0000000..14b2c73
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.js
@@ -0,0 +1,148 @@
+// Copyright 2020 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 {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {projectAndUserToStarName} from 'shared/converters.js';
+import {MrStar} from './mr-star.js';
+import 'shared/typedef.js';
+
+
+/**
+ * `<mr-project-star>`
+ *
+ * A button for starring a project.
+ *
+ */
+export class MrProjectStar extends connectStore(MrStar) {
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Resource name of the project being starred.
+ */
+ name: {type: String},
+ /**
+ * List of all stars, indexed by star name.
+ */
+ _stars: {type: Object},
+ /**
+ * Whether project stars are currently being fetched.
+ */
+ _fetchingStars: {type: Boolean},
+ /**
+ * Request data for projects currently being starred.
+ */
+ _starringProjects: {type: Object},
+ /**
+ * Request data for projects currently being unstarred.
+ */
+ _unstarringProjects: {type: Object},
+ /**
+ * The currently logged in user. Required to determine if the user can
+ * star.
+ */
+ _currentUserName: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ /** @type {string} */
+ this.name = undefined;
+
+ /** @type {boolean} */
+ this._fetchingStars = false;
+
+ /** @type {Object<ProjectStarName, ReduxRequestState>} */
+ this._starringProjects = {};
+
+ /** @type {Object<ProjectStarName, ReduxRequestState>} */
+ this._unstarringProjects = {};
+
+ /** @type {Object<StarName, Star>} */
+ this._stars = {};
+
+ /** @type {string} */
+ this._currentUserName = undefined;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._currentUserName = users.currentUserName(state);
+
+ this._stars = stars.byName(state);
+
+ const requests = stars.requests(state);
+ this._fetchingStars = requests.listProjects.requesting;
+ this._starringProjects = requests.starProject;
+ this._unstarringProjects = requests.unstarProject;
+ }
+
+ /** @override */
+ get type() {
+ return 'project';
+ }
+
+ /**
+ * @return {string} The resource name of the ProjectStar.
+ */
+ get _starName() {
+ return projectAndUserToStarName(this.name, this._currentUserName);
+ }
+
+ /**
+ * @return {ProjectStar} The ProjectStar object for the referenced project,
+ * if one exists.
+ */
+ get _projectStar() {
+ const name = this._starName;
+ if (!(name in this._stars)) return {};
+ return this._stars[name];
+ }
+
+ /**
+ * @return {boolean} Whether there's an in-flight star request.
+ */
+ get _isStarring() {
+ const requestKey = this._starName;
+ if (requestKey in this._starringProjects &&
+ this._starringProjects[requestKey].requesting) {
+ return true;
+ }
+ if (requestKey in this._unstarringProjects &&
+ this._unstarringProjects[requestKey].requesting) {
+ return true;
+ }
+ return false;
+ }
+
+ /** @override */
+ get isLoggedIn() {
+ return !!this._currentUserName;
+ }
+
+ /** @override */
+ get requesting() {
+ return this._fetchingStars || this._isStarring;
+ }
+
+ /** @override */
+ get isStarred() {
+ return !!(this._projectStar && this._projectStar.name);
+ }
+
+ /** @override */
+ star() {
+ store.dispatch(stars.starProject(this.name, this._currentUserName));
+ }
+
+ /** @override */
+ unstar() {
+ store.dispatch(stars.unstarProject(this.name, this._currentUserName));
+ }
+}
+
+customElements.define('mr-project-star', MrProjectStar);
diff --git a/static_src/elements/framework/mr-star/mr-project-star.test.js b/static_src/elements/framework/mr-star/mr-project-star.test.js
new file mode 100644
index 0000000..6afd982
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.test.js
@@ -0,0 +1,181 @@
+// Copyright 2020 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 {MrProjectStar} from './mr-project-star.js';
+import {stars} from 'reducers/stars.js';
+
+let element;
+
+describe('mr-project-star (disconnected)', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-project-star');
+ document.body.appendChild(element);
+
+ sinon.stub(element, 'stateChanged');
+ sinon.spy(stars, 'starProject');
+ sinon.spy(stars, 'unstarProject');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ stars.starProject.restore();
+ stars.unstarProject.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrProjectStar);
+ });
+
+ it('clicking on star when logged out logs in user', async () => {
+ element._currentUserName = undefined;
+ sinon.stub(element, 'login');
+
+ await element.updateComplete;
+
+ const star = element.shadowRoot.querySelector('button');
+
+ star.click();
+
+ sinon.assert.calledOnce(element.login);
+ });
+
+ it('star dispatches star request', () => {
+ element._currentUserName = 'users/1234';
+ element.name = 'projects/monorail';
+
+ element.star();
+
+ sinon.assert.calledWith(stars.starProject,
+ 'projects/monorail', 'users/1234');
+ });
+
+ it('unstar dispatches unstar request', () => {
+ element._currentUserName = 'users/1234';
+ element.name = 'projects/monorail';
+
+ element.unstar();
+
+ sinon.assert.calledWith(stars.unstarProject,
+ 'projects/monorail', 'users/1234');
+ });
+
+ describe('isStarred', () => {
+ beforeEach(() => {
+ element._stars = {
+ 'users/1234/projectStars/monorail':
+ {name: 'users/1234/projectStars/monorail'},
+ 'users/5678/projectStars/chromium':
+ {name: 'users/5678/projectStars/chromium'},
+ };
+ });
+
+ it('false when no data', () => {
+ element._stars = {};
+ assert.isFalse(element.isStarred);
+ });
+
+ it('false when user is not logged in', () => {
+ element._currentUserName = '';
+ element.name = 'projects/monorail';
+
+ assert.isFalse(element.isStarred);
+ });
+
+ it('false when project is not starred', () => {
+ element._currentUserName = 'users/1234';
+ element.name = 'projects/chromium';
+
+ assert.isFalse(element.isStarred);
+
+ element._currentUserName = 'users/5678';
+ element.name = 'projects/monorail';
+
+ assert.isFalse(element.isStarred);
+ });
+
+ it('true when user has starred project', () => {
+ element._currentUserName = 'users/1234';
+ element.name = 'projects/monorail';
+
+ assert.isTrue(element.isStarred);
+
+ element._currentUserName = 'users/5678';
+ element.name = 'projects/chromium';
+
+ assert.isTrue(element.isStarred);
+ });
+ });
+
+ describe('_starringEnabled', () => {
+ beforeEach(() => {
+ element._currentUserName = 'users/1234';
+ element.name = 'projects/monorail';
+ });
+
+ it('disabled when user is not logged in', () => {
+ element._currentUserName = '';
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('disabled when stars are being fetched', () => {
+ element._fetchingStars = true;
+ element._starringProjects = {};
+ element._unstarringProjects = {};
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('disabled when user is starring project', () => {
+ element._fetchingStars = false;
+ element._starringProjects =
+ {'users/1234/projectStars/monorail': {requesting: true}};
+ element._unstarringProjects = {};
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('disabled when user is unstarring project', () => {
+ element._fetchingStars = false;
+ element._starringProjects = {};
+ element._unstarringProjects =
+ {'users/1234/projectStars/monorail': {requesting: true}};
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('enabled when user is starring an unrelated project', () => {
+ element._fetchingStars = false;
+ element._starringProjects = {
+ 'users/1234/projectStars/chromium': {requesting: true},
+ 'users/1234/projectStars/monorail': {requesting: false},
+ };
+ element._unstarringProjects = {};
+
+ assert.isTrue(element._starringEnabled);
+ });
+
+ it('enabled when user is unstarring an unrelated project', () => {
+ element._fetchingStars = false;
+ element._starringProjects = {};
+ element._unstarringProjects = {
+ 'users/1234/projectStars/chromium': {requesting: true},
+ 'users/1234/projectStars/monorail': {requesting: false},
+ };
+
+ assert.isTrue(element._starringEnabled);
+ });
+
+ it('enabled when no in-flight requests', () => {
+ element._fetchingStars = false;
+ element._starringProjects = {};
+ element._unstarringProjects = {};
+
+ assert.isTrue(element._starringEnabled);
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-star/mr-star.js b/static_src/elements/framework/mr-star/mr-star.js
new file mode 100644
index 0000000..fe509be
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.js
@@ -0,0 +1,235 @@
+// 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';
+
+/**
+ * `<mr-star>`
+ *
+ * A button for starring a resource. Does not directly integrate with app
+ * state. Subclasses by <mr-issue-star> and <mr-project-star>, which add
+ * resource-specific logic for state management.
+ *
+ */
+export class MrStar extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ --mr-star-size: var(--chops-icon-font-size);
+ }
+ button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ }
+ /* TODO(crbug.com/monorail/8008): Add nicer looking loading style. */
+ button.loading {
+ opacity: 0.5;
+ cursor: default;
+ }
+ i.material-icons {
+ font-size: var(--mr-star-size);
+ color: var(--chops-primary-icon-color);
+ }
+ i.material-icons.starred {
+ color: var(--chops-primary-accent-color);
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ const {isStarred} = this;
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <button class="star-button"
+ @click=${this._loginOrStar}
+ title=${this._starToolTip}
+ role="checkbox"
+ aria-checked=${isStarred ? 'true' : 'false'}
+ class=${this.requesting ? 'loading' : ''}
+ >
+ ${isStarred ? html`
+ <i class="material-icons starred" role="presentation">
+ star
+ </i>
+ `: html`
+ <i class="material-icons" role="presentation">
+ star_border
+ </i>
+ `}
+ </button>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Note: In order for re-renders to happen based on the getters defined
+ * in this class, those getters must have values based on properties.
+ * Subclasses of <mr-star> are not expected to inherit <mr-star>'s
+ * properties, but they should make sure their getter implementations
+ * are also backed by properties.
+ */
+ _isStarred: {type: Boolean},
+ _isLoggedIn: {type: Boolean},
+ _canStar: {type: Boolean},
+ _requesting: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ /**
+ * @type {boolean} Whether the user has starred the resource or not.
+ */
+ this._isStarred = false;
+
+ /**
+ * @type {boolean} If the user is logged in.
+ */
+ this._isLoggedIn = false;
+
+ /**
+ * @return {boolean} Whether the user has permission to star the star.
+ */
+ this._canStar = true;
+
+ /**
+ * @return {boolean} Whether there's an in-flight request to star
+ * the resource.
+ */
+ this._requesting = false;
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ // Prevent clicks on this element from causing navigation if the element
+ // is embedded inside a link.
+ this.addEventListener('click', (e) => e.preventDefault());
+ }
+
+ /**
+ * @return {boolean} If the user is logged in.
+ */
+ get isLoggedIn() {
+ return this._isLoggedIn;
+ }
+
+ /**
+ * @return {boolean} If there's an in-flight request that might affect the
+ * star's data.
+ */
+ get requesting() {
+ return this._requesting;
+ }
+
+ /**
+ * @return {boolean} Whether the resource is starred or not.
+ */
+ get isStarred() {
+ return this._isStarred;
+ }
+
+ /**
+ * @return {boolean} If the user has permission to star.
+ */
+ get canStar() {
+ return this._canStar;
+ }
+
+ /**
+ * @return {boolean}
+ */
+ get _starringEnabled() {
+ return this.isLoggedIn && this.canStar && !this.requesting;
+ }
+
+ /**
+ * @return {string} The name of the resource kind being starred.
+ * ie: issue, project, etc.
+ */
+ get type() {
+ return 'resource';
+ }
+
+ /**
+ * @return {string} the title to display on the star button.
+ */
+ get _starToolTip() {
+ if (!this.isLoggedIn) {
+ return `Login to star this ${this.type}.`;
+ }
+ if (!this.canStar) {
+ return `You don't have permission to star this ${this.type}.`;
+ }
+ if (this.requesting) {
+ return `Loading star state for this ${this.type}.`;
+ }
+ return `${this.isStarred ? 'Unstar' : 'Star'} this ${this.type}.`;
+ }
+
+ /**
+ * Logins the user if they're not logged in. Otherwise, stars or
+ * unstars the resource based on star state.
+ */
+ _loginOrStar() {
+ if (!this.isLoggedIn) {
+ this.login();
+ } else {
+ this.toggleStar();
+ }
+ }
+
+ /**
+ * Logs in the user.
+ */
+ login() {
+ // TODO(crbug.com/monorail/6073): Replace this logic with a function call
+ // when moving authentication to frontend.
+ // HACK: In our current login implementation, login URLs can only be
+ // generated by the backend which makes piping a login URL into a component
+ // a <mr-star> complex. To get around this, we're using the
+ // legacy window.CS_env infrastructure.
+ window.location.href = window.CS_env.login_url;
+ }
+
+ /**
+ * Stars or unstars the resource based on the user's interaction.
+ */
+ toggleStar() {
+ if (!this._starringEnabled) return;
+ if (this.isStarred) {
+ this.unstar();
+ } else {
+ this.star();
+ }
+ }
+
+ /**
+ * Stars the given resource. To be implemented by a subclass.
+ */
+ star() {
+ throw new Error('Method not implemented.');
+ }
+
+ /**
+ * Unstars the given resource. To be implemented by a subclass.
+ */
+ unstar() {
+ throw new Error('Method not implemented.');
+ }
+}
+
+customElements.define('mr-star', MrStar);
diff --git a/static_src/elements/framework/mr-star/mr-star.test.js b/static_src/elements/framework/mr-star/mr-star.test.js
new file mode 100644
index 0000000..4db7877
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.test.js
@@ -0,0 +1,302 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+
+import {MrStar} from './mr-star.js';
+
+let element;
+
+describe('mr-star', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-star');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ if (document.body.contains(element)) {
+ document.body.removeChild(element);
+ }
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrStar);
+ });
+
+ it('unimplemented methods throw errors', () => {
+ assert.throws(element.star, 'Method not implemented.');
+ assert.throws(element.unstar, 'Method not implemented.');
+ });
+
+ describe('clicking star toggles star state', () => {
+ beforeEach(() => {
+ sinon.stub(element, 'star');
+ sinon.stub(element, 'unstar');
+ element._isLoggedIn = true;
+ element._canStar = true;
+ });
+
+ it('unstarred star', async () => {
+ element._isStarred = false;
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.star);
+ sinon.assert.notCalled(element.unstar);
+
+ element.shadowRoot.querySelector('button').click();
+
+ sinon.assert.calledOnce(element.star);
+ sinon.assert.notCalled(element.unstar);
+ });
+
+ it('starred star', async () => {
+ element._isStarred = true;
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.star);
+ sinon.assert.notCalled(element.unstar);
+
+ element.shadowRoot.querySelector('button').click();
+
+ sinon.assert.notCalled(element.star);
+ sinon.assert.calledOnce(element.unstar);
+ });
+ });
+
+ it('clicking while logged out logs you in', async () => {
+ sinon.stub(element, 'login');
+ element._isLoggedIn = false;
+ element._canStar = true;
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.login);
+
+ element.shadowRoot.querySelector('button').click();
+
+ sinon.assert.calledOnce(element.login);
+ });
+
+ describe('toggleStar', () => {
+ beforeEach(() => {
+ sinon.stub(element, 'star');
+ sinon.stub(element, 'unstar');
+ });
+
+ it('stars when unstarred', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._isStarred = false;
+
+ element.toggleStar();
+
+ sinon.assert.calledOnce(element.star);
+ sinon.assert.notCalled(element.unstar);
+ });
+
+ it('unstars when starred', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._isStarred = true;
+
+ element.toggleStar();
+
+ sinon.assert.calledOnce(element.unstar);
+ sinon.assert.notCalled(element.star);
+ });
+
+ it('does nothing when user is not logged in', () => {
+ element._isLoggedIn = false;
+ element._canStar = true;
+ element._isStarred = true;
+
+ element.toggleStar();
+
+ sinon.assert.notCalled(element.unstar);
+ sinon.assert.notCalled(element.star);
+ });
+
+ it('does nothing when user does not have permission', () => {
+ element._isLoggedIn = true;
+ element._canStar = false;
+ element._isStarred = true;
+
+ element.toggleStar();
+
+ sinon.assert.notCalled(element.unstar);
+ sinon.assert.notCalled(element.star);
+ });
+
+ it('does nothing when stars are being fetched', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._isStarred = true;
+ element._requesting = true;
+
+ element.toggleStar();
+
+ sinon.assert.notCalled(element.unstar);
+ sinon.assert.notCalled(element.star);
+ });
+ });
+
+ describe('_starringEnabled', () => {
+ it('enabled when user is logged in and has permission', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._isStarred = true;
+ element._requesting = false;
+
+ assert.isTrue(element._starringEnabled);
+ });
+
+ it('disabled when user is logged out', () => {
+ element._isLoggedIn = false;
+ element._canStar = false;
+ element._isStarred = false;
+ element._requesting = false;
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('disabled when user has no permission', () => {
+ element._isLoggedIn = true;
+ element._canStar = false;
+ element._isStarred = true;
+ element._requesting = false;
+
+ assert.isFalse(element._starringEnabled);
+ });
+
+ it('disabled when requesting star', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._isStarred = true;
+ element._requesting = true;
+
+ assert.isFalse(element._starringEnabled);
+ });
+ });
+
+ it('loading state shown when requesting', async () => {
+ element._requesting = true;
+ await element.updateComplete;
+
+ const star = element.shadowRoot.querySelector('button');
+
+ assert.isTrue(star.classList.contains('loading'));
+
+ element._requesting = false;
+ await element.updateComplete;
+
+ assert.isFalse(star.classList.contains('loading'));
+ });
+
+ it('isStarred changes displayed icon', async () => {
+ element._isStarred = true;
+ await element.updateComplete;
+
+ const star = element.shadowRoot.querySelector('button');
+ assert.equal(star.textContent.trim(), 'star');
+
+ element._isStarred = false;
+ await element.updateComplete;
+
+ assert.equal(star.textContent.trim(), 'star_border');
+ });
+
+ describe('mr-star nested inside a link', () => {
+ let parent;
+ let oldHash;
+
+ beforeEach(() => {
+ parent = document.createElement('a');
+ parent.setAttribute('href', '#test-hash');
+ parent.appendChild(element);
+
+ oldHash = window.location.hash;
+
+ sinon.stub(element, 'star');
+ sinon.stub(element, 'unstar');
+ });
+
+ afterEach(() => {
+ window.location.hash = oldHash;
+ });
+
+ it('clicking to star does not cause navigation', async () => {
+ sinon.spy(element, 'toggleStar');
+ element._isLoggedIn = true;
+ element._canStar = true;
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('button').click();
+
+ assert.notEqual(window.location.hash, '#test-hash');
+ sinon.assert.calledOnce(element.toggleStar);
+ });
+
+ it('clicking on disabled star does not cause navigation', async () => {
+ element._isLoggedIn = true;
+ element._canStar = false;
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('button').click();
+
+ assert.notEqual(window.location.hash, '#test-hash');
+ });
+
+ it('clicking on link still navigates', async () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ await element.updateComplete;
+
+ parent.click();
+
+ assert.equal(window.location.hash, '#test-hash');
+ });
+ });
+
+ describe('_starToolTip', () => {
+ it('not logged in', () => {
+ element._isLoggedIn = false;
+ element._canStar = false;
+ assert.equal(element._starToolTip,
+ `Login to star this resource.`);
+ });
+
+ it('no permission to star', () => {
+ element._isLoggedIn = true;
+ element._canStar = false;
+ assert.equal(element._starToolTip,
+ `You don't have permission to star this resource.`);
+ });
+
+ it('star is loading', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._requesting = true;
+ assert.equal(element._starToolTip,
+ `Loading star state for this resource.`);
+ });
+
+ it('issue is not starred', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._isStarred = false;
+ assert.equal(element._starToolTip,
+ `Star this resource.`);
+ });
+
+ it('issue is starred', () => {
+ element._isLoggedIn = true;
+ element._canStar = true;
+ element._isStarred = true;
+ assert.equal(element._starToolTip,
+ `Unstar this resource.`);
+ });
+ });
+});
diff --git a/static_src/elements/framework/mr-tabs/mr-tabs.js b/static_src/elements/framework/mr-tabs/mr-tabs.js
new file mode 100644
index 0000000..d14688e
--- /dev/null
+++ b/static_src/elements/framework/mr-tabs/mr-tabs.js
@@ -0,0 +1,99 @@
+// Copyright 2020 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 'shared/typedef.js';
+
+/**
+ * `<mr-tabs>`
+ *
+ * A Material Design tabs strip. https://material.io/components/tabs/
+ *
+ */
+export class MrTabs extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ ul {
+ display: flex;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+ li {
+ color: var(--chops-choice-color);
+ }
+ li.selected {
+ color: var(--chops-active-choice-color);
+ }
+ li:hover {
+ background: var(--chops-primary-accent-bg);
+ color: var(--chops-active-choice-color);
+ }
+ a {
+ color: inherit;
+ text-decoration: none;
+
+ display: inline-block;
+ line-height: 38px;
+ padding: 0 24px;
+ }
+ li.selected a {
+ border-bottom: solid 2px;
+ }
+ i.material-icons {
+ vertical-align: middle;
+ margin-right: 4px;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <ul>
+ ${this.items.map(this._renderTab.bind(this))}
+ </ul>
+ `;
+ }
+
+ /**
+ * Renders one tab.
+ * @param {MenuItem} item
+ * @param {number} index
+ * @return {TemplateResult}
+ */
+ _renderTab(item, index) {
+ return html`
+ <li class=${index === this.selected ? 'selected' : ''}>
+ <a href=${item.url}>
+ <i class="material-icons" ?hidden=${!item.icon}>
+ ${item.icon}
+ </i>
+ ${item.text}
+ </a>
+ </li>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ items: {type: Array},
+ selected: {type: Number},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** @type {Array<MenuItem>} */
+ this.items = [];
+ this.selected = 0;
+ }
+}
+
+customElements.define('mr-tabs', MrTabs);
diff --git a/static_src/elements/framework/mr-tabs/mr-tabs.test.js b/static_src/elements/framework/mr-tabs/mr-tabs.test.js
new file mode 100644
index 0000000..1d55c39
--- /dev/null
+++ b/static_src/elements/framework/mr-tabs/mr-tabs.test.js
@@ -0,0 +1,38 @@
+// Copyright 2020 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 {MrTabs} from './mr-tabs.js';
+
+/** @type {MrTabs} */
+let element;
+
+describe('mr-tabs', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ element = document.createElement('mr-tabs');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrTabs);
+ });
+
+ it('renders tabs', async () => {
+ element.items = [
+ {text: 'Text 1'},
+ {text: 'Text 2', icon: 'done', url: 'https://url'},
+ ];
+ element.selected = 1;
+ await element.updateComplete;
+
+ const items = element.shadowRoot.querySelectorAll('li');
+ assert.equal(items[0].className, '');
+ assert.equal(items[1].className, 'selected');
+ });
+});
diff --git a/static_src/elements/framework/mr-upload/mr-upload.js b/static_src/elements/framework/mr-upload/mr-upload.js
new file mode 100644
index 0000000..5fee672
--- /dev/null
+++ b/static_src/elements/framework/mr-upload/mr-upload.js
@@ -0,0 +1,322 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-upload>`
+ *
+ * A file uploading widget for use in adding attachments and similar things.
+ *
+ */
+export class MrUpload extends LitElement {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: block;
+ width: 100%;
+ padding: 0.25em 4px;
+ border: 1px dashed var(--chops-gray-300);
+ box-sizing: border-box;
+ border-radius: 8px;
+ transition: background 0.2s ease-in-out,
+ border-color 0.2s ease-in-out;
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ :host([expanded]) {
+ /* Expand the drag and drop area when a file is being dragged. */
+ min-height: 120px;
+ }
+ :host([highlighted]) {
+ border-color: var(--chops-primary-accent-color);
+ background: var(--chops-active-choice-bg);
+ }
+ input[type="file"] {
+ /* We need the file uploader to be hidden but still accessible. */
+ opacity: 0;
+ width: 0;
+ height: 0;
+ position: absolute;
+ top: -9999;
+ left: -9999;
+ }
+ input[type="file"]:focus + label {
+ /* TODO(zhangtiff): Find a way to either mimic native browser focus
+ * styles or make focus styles more consistent. */
+ box-shadow: 0 0 3px 1px hsl(193, 82%, 63%);
+ }
+ label.button {
+ margin-right: 8px;
+ padding: 0.1em 4px;
+ display: inline-flex;
+ width: auto;
+ cursor: pointer;
+ border: var(--chops-normal-border);
+ margin-left: 0;
+ }
+ label.button i.material-icons {
+ font-size: var(--chops-icon-font-size);
+ }
+ ul {
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ flex-direction: column;
+ }
+ ul[hidden] {
+ display: none;
+ }
+ li {
+ display: inline-flex;
+ align-items: center;
+ }
+ li i.material-icons {
+ font-size: 14px;
+ margin: 0;
+ }
+ /* TODO(zhangtiff): Create a shared Material icon button component. */
+ button {
+ border-radius: 50%;
+ cursor: pointer;
+ background: 0;
+ border: 0;
+ padding: 0.25em;
+ margin-left: 4px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s ease-in-out;
+ }
+ button:hover {
+ background: var(--chops-gray-200);
+ }
+ .controls {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <div class="controls">
+ <input id="file-uploader" type="file" multiple @change=${this._filesChanged}>
+ <label class="button" for="file-uploader">
+ <i class="material-icons" role="presentation">attach_file</i>Add attachments
+ </label>
+ Drop files here to add them (Max: 10.0 MB per comment)
+ </div>
+ <ul ?hidden=${!this.files || !this.files.length}>
+ ${this.files.map((file, i) => html`
+ <li>
+ ${file.name}
+ <button data-index=${i} @click=${this._removeFile}>
+ <i class="material-icons">clear</i>
+ </button>
+ </li>
+ `)}
+ </ul>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ files: {type: Array},
+ highlighted: {
+ type: Boolean,
+ reflect: true,
+ },
+ expanded: {
+ type: Boolean,
+ reflect: true,
+ },
+ _boundOnDragIntoWindow: {type: Object},
+ _boundOnDragOutOfWindow: {type: Object},
+ _boundOnDragInto: {type: Object},
+ _boundOnDragLeave: {type: Object},
+ _boundOnDrop: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.expanded = false;
+ this.highlighted = false;
+ this.files = [];
+ this._boundOnDragIntoWindow = this._onDragIntoWindow.bind(this);
+ this._boundOnDragOutOfWindow = this._onDragOutOfWindow.bind(this);
+ this._boundOnDragInto = this._onDragInto.bind(this);
+ this._boundOnDragLeave = this._onDragLeave.bind(this);
+ this._boundOnDrop = this._onDrop.bind(this);
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ this.addEventListener('dragenter', this._boundOnDragInto);
+ this.addEventListener('dragover', this._boundOnDragInto);
+
+ this.addEventListener('dragleave', this._boundOnDragLeave);
+ this.addEventListener('drop', this._boundOnDrop);
+
+ window.addEventListener('dragenter', this._boundOnDragIntoWindow);
+ window.addEventListener('dragover', this._boundOnDragIntoWindow);
+ window.addEventListener('dragleave', this._boundOnDragOutOfWindow);
+ window.addEventListener('drop', this._boundOnDragOutOfWindow);
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ window.removeEventListener('dragenter', this._boundOnDragIntoWindow);
+ window.removeEventListener('dragover', this._boundOnDragIntoWindow);
+ window.removeEventListener('dragleave', this._boundOnDragOutOfWindow);
+ window.removeEventListener('drop', this._boundOnDragOutOfWindow);
+ }
+
+ reset() {
+ this.files = [];
+ }
+
+ get hasAttachments() {
+ return this.files.length !== 0;
+ }
+
+ async loadFiles() {
+ // TODO(zhangtiff): Add preloading of files on change.
+ if (!this.files || !this.files.length) return [];
+ const loads = this.files.map(this._loadLocalFile);
+ return await Promise.all(loads);
+ }
+
+ _onDragInto(e) {
+ // Combined event handler for dragenter and dragover.
+ if (!this._eventGetFiles(e).length) return;
+ e.preventDefault();
+ this.highlighted = true;
+ }
+
+ _onDragLeave(e) {
+ // Unhighlight the drop area when the user undrops the component.
+ if (!this._eventGetFiles(e).length) return;
+ e.preventDefault();
+ this.highlighted = false;
+ }
+
+ _onDrop(e) {
+ // Add the files the user is dragging when dragging into the component.
+ const files = this._eventGetFiles(e);
+ if (!files.length) return;
+ e.preventDefault();
+ this.highlighted = false;
+ this._addFiles(files);
+ }
+
+ _onDragIntoWindow(e) {
+ // Expand the drop area when any file is being dragged in the window.
+ if (!this._eventGetFiles(e).length) return;
+ e.preventDefault();
+ this.expanded = true;
+ }
+
+ _onDragOutOfWindow(e) {
+ // Unexpand the component when a file is no longer being dragged.
+ if (!this._eventGetFiles(e).length) return;
+ e.preventDefault();
+ this.expanded = false;
+ }
+
+ _eventGetFiles(e) {
+ if (!e || !e.dataTransfer) return [];
+ const dt = e.dataTransfer;
+
+ if (dt.items && dt.items.length) {
+ const filteredItems = [...dt.items].filter(
+ (item) => item.kind === 'file');
+ return filteredItems.map((item) => item.getAsFile());
+ }
+
+ return [...dt.files];
+ }
+
+ _loadLocalFile(f) {
+ // The FileReader API only accepts callbacks for asynchronous handling,
+ // so it's easier to use Promises here. But by wrapping this logic
+ // in a Promise, we can use async/await in outer code.
+ return new Promise((resolve, reject) => {
+ const r = new FileReader();
+ r.onloadend = () => {
+ resolve({filename: f.name, content: btoa(r.result)});
+ };
+ r.onerror = () => {
+ reject(r.error);
+ };
+
+ r.readAsBinaryString(f);
+ });
+ }
+
+ /**
+ * @param {Event} e
+ * @fires CustomEvent#change
+ * @private
+ */
+ _filesChanged(e) {
+ const input = e.currentTarget;
+ if (!input.files) return;
+ this._addFiles(input.files);
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+
+ _addFiles(newFiles) {
+ if (!newFiles) return;
+ // Spread files to convert it from a FileList to an Array.
+ const files = [...newFiles].filter((f1) => {
+ const matchingFile = this.files.some((f2) => this._filesMatch(f1, f2));
+ return !matchingFile;
+ });
+
+ this.files = this.files.concat(files);
+ }
+
+ _filesMatch(a, b) {
+ // NOTE: This function could return a false positive if two files have the
+ // exact same name, lastModified time, size, and type but different
+ // content. This is extremely unlikely, however.
+ return a.name === b.name && a.lastModified === b.lastModified &&
+ a.size === b.size && a.type === b.type;
+ }
+
+ _removeFile(e) {
+ const target = e.currentTarget;
+
+ // This should always be an int.
+ const index = Number.parseInt(target.dataset.index);
+ if (index < 0 || index >= this.files.length) return;
+
+ this.files.splice(index, 1);
+
+ // Trigger an update.
+ this.files = [...this.files];
+ }
+}
+customElements.define('mr-upload', MrUpload);
diff --git a/static_src/elements/framework/mr-upload/mr-upload.test.js b/static_src/elements/framework/mr-upload/mr-upload.test.js
new file mode 100644
index 0000000..0a0b1e8
--- /dev/null
+++ b/static_src/elements/framework/mr-upload/mr-upload.test.js
@@ -0,0 +1,218 @@
+// 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 {MrUpload} from './mr-upload.js';
+
+let element;
+let preventDefault;
+let mockEvent;
+
+
+describe('mr-upload', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-upload');
+ document.body.appendChild(element);
+
+ preventDefault = sinon.stub();
+
+ mockEvent = (properties) => {
+ return Object.assign({
+ preventDefault: preventDefault,
+ }, properties);
+ };
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrUpload);
+ });
+
+ it('reset clears files', () => {
+ element.files = [new File([''], 'filename.txt'), new File([''], 'hello')];
+
+ element.reset();
+
+ assert.deepEqual(element.files, []);
+ });
+
+ it('editing file selector adds files', () => {
+ const files = [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ ];
+ assert.deepEqual(element.files, []);
+
+ // NOTE: There is currently no way to use JavaScript to set the value of
+ // an HTML file input.
+
+ element._filesChanged({
+ currentTarget: {
+ files: files,
+ },
+ });
+
+ assert.deepEqual(element.files, files);
+ });
+
+ it('files are rendered', async () => {
+ element.files = [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ new File([''], 'file.png'),
+ ];
+
+ await element.updateComplete;
+
+ const items = element.shadowRoot.querySelectorAll('li');
+
+ assert.equal(items.length, 3);
+
+ assert.include(items[0].textContent, 'filename.txt');
+ assert.include(items[1].textContent, 'hello');
+ assert.include(items[2].textContent, 'file.png');
+ });
+
+ it('clicking removes file', async () => {
+ element.files = [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ new File([''], 'file.png'),
+ ];
+
+ await element.updateComplete;
+
+ let items = element.shadowRoot.querySelectorAll('li');
+
+ assert.equal(items.length, 3);
+
+ items[1].querySelector('button').click();
+
+ await element.updateComplete;
+
+ items = element.shadowRoot.querySelectorAll('li');
+
+ assert.equal(items.length, 2);
+
+ assert.include(items[0].textContent, 'filename.txt');
+ assert.include(items[1].textContent, 'file.png');
+
+ // Make sure clicking works even for children targets.
+ items[0].querySelector('i.material-icons').click();
+
+ await element.updateComplete;
+
+ items = element.shadowRoot.querySelectorAll('li');
+
+ assert.equal(items.length, 1);
+
+ assert.include(items[0].textContent, 'file.png');
+ });
+
+ it('duplicate files are ignored', () => {
+ const file1 = new File([''], 'filename.txt');
+ const file2 = new File([''], 'woahhh');
+ const file3 = new File([''], 'filename');
+
+ element.files = [file1, file2];
+
+ element._addFiles([file2, file3]);
+
+ assert.deepEqual(element.files, [file1, file2, file3]);
+ });
+
+ it('dragging file into window expands element', () => {
+ assert.isFalse(element.expanded);
+ assert.deepEqual(element.files, []);
+
+ element._onDragIntoWindow(mockEvent({dataTransfer: {files: [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ ]}}));
+
+ assert.isTrue(element.expanded);
+ assert.deepEqual(element.files, []);
+ assert.isTrue(preventDefault.calledOnce);
+
+ element._onDragOutOfWindow(mockEvent({dataTransfer: {files: [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ ]}}));
+
+ assert.isFalse(element.expanded);
+ assert.deepEqual(element.files, []);
+ assert.isTrue(preventDefault.calledTwice);
+ });
+
+ it('dragging non-file into window does not expands element', () => {
+ assert.isFalse(element.expanded);
+
+ element._onDragIntoWindow(mockEvent(
+ {dataTransfer: {files: [], items: [{kind: 'notFile'}]}},
+ ));
+
+ assert.isFalse(element.expanded);
+ assert.isFalse(preventDefault.called);
+
+ element._onDragOutOfWindow(mockEvent(
+ {dataTransfer: {files: [], items: [{kind: 'notFile'}]}},
+ ));
+
+ assert.isFalse(element.expanded);
+ assert.isFalse(preventDefault.called);
+ });
+
+ it('dragging file over element highlights it', () => {
+ assert.isFalse(element.highlighted);
+ assert.deepEqual(element.files, []);
+
+ element._onDragInto(mockEvent({dataTransfer: {files: [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ ]}}));
+
+ assert.isTrue(element.highlighted);
+ assert.deepEqual(element.files, []);
+ assert.isTrue(preventDefault.calledOnce);
+
+ element._onDragLeave(mockEvent({dataTransfer: {files: [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ ]}}));
+
+ assert.isFalse(element.highlighted);
+ assert.deepEqual(element.files, []);
+ assert.isTrue(preventDefault.calledTwice);
+ });
+
+ it('dropping file over element selects it', () => {
+ const files = [
+ new File([''], 'filename.txt'),
+ new File([''], 'hello'),
+ ];
+ assert.deepEqual(element.files, []);
+
+ element._onDrop(mockEvent({dataTransfer: {files: files}}));
+
+ assert.isTrue(preventDefault.calledOnce);
+ assert.deepEqual(element.files, files);
+ });
+
+ it('loadFiles loads files', async () => {
+ element.files = [
+ new File(['some content'], 'filename.txt'),
+ new File([''], 'hello'),
+ ];
+
+ const uploads = await element.loadFiles();
+
+ assert.deepEqual(uploads, [
+ {content: 'c29tZSBjb250ZW50', filename: 'filename.txt'},
+ {content: '', filename: 'hello'},
+ ]);
+ });
+});
diff --git a/static_src/elements/framework/mr-warning/mr-warning.js b/static_src/elements/framework/mr-warning/mr-warning.js
new file mode 100644
index 0000000..51de376
--- /dev/null
+++ b/static_src/elements/framework/mr-warning/mr-warning.js
@@ -0,0 +1,51 @@
+// 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';
+
+
+/**
+ * `<mr-warning>`
+ *
+ * A container for showing warnings.
+ *
+ */
+export class MrWarning extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: flex-start;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0.5em 0;
+ padding: 0.25em 8px;
+ border: 1px solid #FF6F00;
+ border-radius: 4px;
+ background: #FFF8E1;
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ i.material-icons {
+ color: #FF6F00;
+ margin-right: 4px;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <i class="material-icons">warning</i>
+ <slot></slot>
+ `;
+ }
+}
+
+customElements.define('mr-warning', MrWarning);
diff --git a/static_src/elements/help/mr-click-throughs/mr-click-throughs.js b/static_src/elements/help/mr-click-throughs/mr-click-throughs.js
new file mode 100644
index 0000000..8b142f0
--- /dev/null
+++ b/static_src/elements/help/mr-click-throughs/mr-click-throughs.js
@@ -0,0 +1,185 @@
+// 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 {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.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';
+
+/**
+ * `<mr-click-throughs>`
+ *
+ * An element that displays help dialogs that the user is required
+ * to click through before they can participate in the community.
+ *
+ */
+export class MrClickThroughs extends connectStore(LitElement) {
+ /** @override */
+ constructor() {
+ super();
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ userDisplayName: {type: String},
+ prefs: {type: Object},
+ prefsLoaded: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [SHARED_STYLES, css`
+ :host {
+ --chops-dialog-max-width: 800px;
+ }
+ h2 {
+ margin-top: 0;
+ display: flex;
+ justify-content: space-between;
+ font-weight: normal;
+ border-bottom: 2px solid white;
+ font-size: var(--chops-large-font-size);
+ padding-bottom: 0.5em;
+ }
+ .edit-actions {
+ width: 100%;
+ margin: 0.5em 0;
+ text-align: right;
+ }
+ `];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <chops-dialog
+ id="privacyDialog"
+ ?opened=${this._showPrivacyDialog}
+ forced
+ >
+ <h2>Email display settings</h2>
+
+ <p>There is a <a href="/hosting/settings">setting</a> to control how
+ your email address appears on comments and issues that you post.</p>
+
+ <p>Project members will always see your full email address. By
+ default, other users who visit the site will see an
+ abbreviated version of your email address.</p>
+
+ <p>If you do not wish your email address to be shared, there
+ are other ways to <a
+ href="http://www.chromium.org/getting-involved">get
+ involved</a> in the community. To report a problem when using
+ the Chrome browser, you may use the "Report an issue..." item
+ on the "Help" menu.</p>
+
+
+ <div class="edit-actions">
+ <chops-button @click=${this.dismissPrivacyDialog}>
+ Got it
+ </chops-button>
+ </div>
+ </chops-dialog>
+
+ <chops-dialog
+ id="corpModeDialog"
+ ?opened=${this._showCorpModeDialog}
+ forced
+ >
+ <h2>This site hosts public issues in public projects</h2>
+
+ <p>Unlike our internal issue tracker, this site makes most
+ issues public, unless the issue is labeled with a Restrict-View-*
+ label, such as Restrict-View-Google.</p>
+
+ <p>Components are not used for permissions. And, regardless of
+ restriction labels, the issue reporter, owner,
+ and Cc'd users may always view the issue.</p>
+
+ ${this.prefs.get('restrict_new_issues') ? html`
+ <p>Your account is a member of a user group that indicates that
+ you may have access to confidential information. To help prevent
+ leaks when working in public projects, the issue tracker UX has
+ been altered for you:</p>
+
+ <ul>
+ <li>When you open a new issue, the form will initially have a
+ Restrict-View-Google label. If you know that your issue does
+ not contain confidential information, please remove the label.</li>
+ <li>When you view public issues, a red banner is shown to remind
+ you that any comments or attachments you post will be public.</li>
+ </ul>
+ ` : ''}
+
+ <div class="edit-actions">
+ <chops-button @click=${this.dismissCorpModeDialog}>
+ Got it
+ </chops-button>
+ </div>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.prefs = userV0.prefs(state);
+ this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
+ }
+
+ /**
+ * Checks whether the user should see a dialogue telling them about
+ * Monorail's privacy settings.
+ */
+ get _showPrivacyDialog() {
+ if (!this.userDisplayName) return false;
+ if (!this.prefsLoaded) return false;
+ if (!this.prefs) return false;
+ if (this.prefs.get('privacy_click_through')) return false;
+ return true;
+ }
+
+ /**
+ * Computes whether the user should see the dialog telling them about corp mode.
+ */
+ get _showCorpModeDialog() {
+ // TODO(jrobbins): Replace this with a API call that gets the project.
+ if (window.CS_env.projectIsRestricted) return false;
+ if (!this.userDisplayName) return false;
+ if (!this.prefsLoaded) return false;
+ if (!this.prefs) return false;
+ if (!this.prefs.get('public_issue_notice')) return false;
+ if (this.prefs.get('corp_mode_click_through')) return false;
+ return true;
+ }
+
+ /**
+ * Event handler for dismissing Monorail's privacy notice.
+ */
+ dismissPrivacyDialog() {
+ this.dismissCue('privacy_click_through');
+ }
+
+ /**
+ * Event handler for dismissing corp mode.
+ */
+ dismissCorpModeDialog() {
+ this.dismissCue('corp_mode_click_through');
+ }
+
+ /**
+ * Dispatches a Redux action to tell Monorail's backend that the user
+ * clicked through a particular cue.
+ * @param {string} pref The pref to set to true.
+ */
+ dismissCue(pref) {
+ const newPrefs = [{name: pref, value: 'true'}];
+ store.dispatch(userV0.setPrefs(newPrefs, !!this.userDisplayName));
+ }
+}
+
+customElements.define('mr-click-throughs', MrClickThroughs);
diff --git a/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js b/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js
new file mode 100644
index 0000000..e735380
--- /dev/null
+++ b/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js
@@ -0,0 +1,120 @@
+// 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 {MrClickThroughs} from './mr-click-throughs.js';
+import page from 'page';
+
+let element;
+
+describe('mr-click-throughs', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-click-throughs');
+ document.body.appendChild(element);
+
+ sinon.stub(page, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ page.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrClickThroughs);
+ });
+
+ it('stateChanged', () => {
+ const state = {userV0: {currentUser:
+ {prefs: new Map(), prefsLoaded: false}}};
+ element.stateChanged(state);
+ assert.deepEqual(element.prefs, new Map([['render_markdown', false]]));
+ assert.isFalse(element.prefsLoaded);
+ });
+
+ it('anon does not see privacy dialog', () => {
+ assert.isFalse(element._showPrivacyDialog);
+ });
+
+ it('signed in user sees no privacy dialog before prefs load', () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = false;
+ assert.isFalse(element._showPrivacyDialog);
+ });
+
+ it('signed in user sees no privacy dialog if dismissal pref set', () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map([['privacy_click_through', true]]);
+ assert.isFalse(element._showPrivacyDialog);
+ });
+
+ it('signed in user sees privacy dialog if dismissal pref missing', () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map();
+ assert.isTrue(element._showPrivacyDialog);
+ });
+
+ it('anon does not see corp mode dialog', () => {
+ assert.isFalse(element._showCorpModeDialog);
+ });
+
+ it('signed in user sees no corp mode dialog before prefs load', () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = false;
+ assert.isFalse(element._showCorpModeDialog);
+ });
+
+ it('signed in user sees no corp mode dialog if dismissal pref set', () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map([['corp_mode_click_through', true]]);
+ assert.isFalse(element._showCorpModeDialog);
+ });
+
+ it('non-corp user sees no corp mode dialog', () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map();
+ assert.isFalse(element._showCorpModeDialog);
+ });
+
+ it('corp user sees corp mode dialog if dismissal pref missing', () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map([['public_issue_notice', true]]);
+ assert.isTrue(element._showCorpModeDialog);
+ });
+
+ it('corp user sees no corp mode dialog in members-only project', () => {
+ window.CS_env = {projectIsRestricted: true};
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map([['public_issue_notice', true]]);
+ assert.isFalse(element._showCorpModeDialog);
+ });
+
+ it('corp user sees corp mode dialog with no RVG warning', async () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map([['public_issue_notice', true]]);
+
+ await element.updateComplete;
+ assert.notInclude(element.shadowRoot.innerHTML, 'altered');
+ });
+
+ it('corp user sees corp mode dialog with RVG warning', async () => {
+ element.userDisplayName = 'user@example.com';
+ element.prefsLoaded = true;
+ element.prefs = new Map([
+ ['public_issue_notice', true],
+ ['restrict_new_issues', true],
+ ]);
+
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'altered');
+ });
+});
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);
diff --git a/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js
new file mode 100644
index 0000000..b7087a9
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js
@@ -0,0 +1,72 @@
+// Copyright 2020 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/framework/mr-tabs/mr-tabs.js';
+
+/** @type {readonly MenuItem[]} */
+const _MENU_ITEMS = Object.freeze([
+ {
+ icon: 'list',
+ text: 'Issues',
+ url: 'issues',
+ },
+ {
+ icon: 'people',
+ text: 'People',
+ url: 'people',
+ },
+ {
+ icon: 'settings',
+ text: 'Settings',
+ url: 'settings',
+ },
+]);
+
+// TODO(dtu): Put this inside <mr-header>. Currently, we can't do this because
+// the sticky table headers rely on having a fixed header height. We need to
+// add a scrolling context to the page in order to have a dynamic-height
+// sticky, and to do that the footer needs to be in the scrolling context. So,
+// the footer needs to be SPA-ified.
+/** Hotlist Issues page */
+export class MrHotlistHeader extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ h1 {
+ font-size: 20px;
+ font-weight: normal;
+ margin: 16px 24px;
+ }
+ nav {
+ border-bottom: solid #ddd 1px;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <nav>
+ <mr-tabs .items=${_MENU_ITEMS} .selected=${this.selected}></mr-tabs>
+ </nav>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ selected: {type: Number},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+ /** @type {number} */
+ this.selected = 0;
+ }
+}
+
+customElements.define('mr-hotlist-header', MrHotlistHeader);
diff --git a/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js
new file mode 100644
index 0000000..9321d59
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js
@@ -0,0 +1,32 @@
+// Copyright 2020 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 {MrHotlistHeader} from './mr-hotlist-header.js';
+
+/** @type {MrHotlistHeader} */
+let element;
+
+describe('mr-hotlist-header', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-header');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrHotlistHeader);
+ });
+
+ it('renders', async () => {
+ element.selected = 2;
+ await element.updateComplete;
+
+ assert.equal(element.shadowRoot.querySelector('mr-tabs').selected, 2);
+ });
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js
new file mode 100644
index 0000000..fa76477
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js
@@ -0,0 +1,361 @@
+// 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 {defaultMemoize} from 'reselect';
+
+import {relativeTime}
+ from 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {issueNameToRef, issueToName, userNameToId}
+ from 'shared/convertersV0.js';
+import {DEFAULT_ISSUE_FIELD_LIST} from 'shared/issue-fields.js';
+
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as ui from 'reducers/ui.js';
+
+import 'elements/chops/chops-filter-chips/chops-filter-chips.js';
+import 'elements/framework/dialogs/mr-change-columns/mr-change-columns.js';
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js';
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
+import 'elements/framework/mr-button-bar/mr-button-bar.js';
+import 'elements/framework/mr-issue-list/mr-issue-list.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+const DEFAULT_HOTLIST_FIELDS = Object.freeze([
+ ...DEFAULT_ISSUE_FIELD_LIST,
+ 'Added',
+ 'Adder',
+ 'Rank',
+]);
+
+/** Hotlist Issues page */
+export class _MrHotlistIssuesPage extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ }
+ section, p, div {
+ margin: 16px 24px;
+ }
+ div {
+ align-items: center;
+ display: flex;
+ }
+ chops-filter-chips {
+ margin-left: 6px;
+ }
+ mr-button-bar {
+ margin: 16px 24px 8px 24px;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <mr-hotlist-header selected=0></mr-hotlist-header>
+ ${this._renderPage()}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderPage() {
+ if (!this._hotlist) {
+ if (this._fetchError) {
+ return html`<section>${this._fetchError.description}</section>`;
+ } else {
+ return html`<section>Loading...</section>`;
+ }
+ }
+
+ // Memoize the issues passed to <mr-issue-list> so that
+ // out property updates don't cause it to re-render.
+ const items = _filterIssues(this._filter, this._items);
+
+ const allProjectNamesEqual = items.length && items.every(
+ (issue) => issue.projectName === items[0].projectName);
+ const projectName = allProjectNamesEqual ? items[0].projectName : null;
+
+ /** @type {HotlistV0} */
+ // Populates <mr-update-issue-hotlists-dialog>' issueHotlists property.
+ const hotlistV0 = {
+ ownerRef: {userId: userNameToId(this._hotlist.owner)},
+ name: this._hotlist.displayName,
+ };
+
+ const mayEdit = this._permissions.includes(hotlists.ADMINISTER) ||
+ this._permissions.includes(hotlists.EDIT);
+ // TODO(https://crbug.com/monorail/7776): The UI to allow reranking of
+ // Issues should reflect user permissions.
+
+ return html`
+ <p>${this._hotlist.summary}</p>
+
+ <div>
+ Filter by Status
+ <chops-filter-chips
+ .options=${['Open', 'Closed']}
+ .selected=${this._filter}
+ @change=${this._onFilterChange}
+ ></chops-filter-chips>
+ </div>
+
+ <mr-button-bar .items=${this._buttonBarItems()}></mr-button-bar>
+
+ <mr-issue-list
+ .issues=${items}
+ .projectName=${projectName}
+ .columns=${this._columns}
+ .defaultFields=${DEFAULT_HOTLIST_FIELDS}
+ .extractFieldValues=${this._extractFieldValues.bind(this)}
+ .rerank=${mayEdit ? this._rerankItems.bind(this) : null}
+ ?selectionEnabled=${mayEdit}
+ @selectionChange=${this._onSelectionChange}
+ ></mr-issue-list>
+
+ <mr-change-columns .columns=${this._columns}></mr-change-columns>
+ <mr-update-issue-hotlists-dialog
+ .issueRefs=${this._selected.map(issueNameToRef)}
+ .issueHotlists=${[hotlistV0]}
+ @saveSuccess=${this._handleHotlistSaveSuccess}
+ ></mr-update-issue-hotlists-dialog>
+ <mr-move-issue-hotlists-dialog
+ .issueRefs=${this._selected.map(issueNameToRef)}
+ @saveSuccess=${this._handleHotlistSaveSuccess}
+ ><mr-move-issue-hotlists-dialog>
+ `;
+ }
+
+ /**
+ * @return {Array<MenuItem>}
+ */
+ _buttonBarItems() {
+ if (this._selected.length) {
+ return [
+ {
+ icon: 'remove_circle_outline',
+ text: 'Remove',
+ handler: this._removeItems.bind(this)},
+ {
+ icon: 'edit',
+ text: 'Update',
+ handler: this._openUpdateIssuesHotlistsDialog.bind(this),
+ },
+ {
+ icon: 'forward',
+ text: 'Move to...',
+ handler: this._openMoveToHotlistDialog.bind(this),
+ },
+ ];
+ } else {
+ return [
+ // TODO(dtu): Implement this action.
+ // {icon: 'add', text: 'Add issues'},
+ {
+ icon: 'table_chart',
+ text: 'Change columns',
+ handler: this._openColumnsDialog.bind(this),
+ },
+ ];
+ }
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ // Populated from Redux.
+ _hotlist: {type: Object},
+ _permissions: {type: Array},
+ _items: {type: Array},
+ _columns: {type: Array},
+ _fetchError: {type: Object},
+ _extractFieldValuesFromIssue: {type: Object},
+
+ // Populated from events.
+ _filter: {type: Object},
+ _selected: {type: Array},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+
+ // Populated from Redux.
+ /** @type {?Hotlist} */
+ this._hotlist = null;
+ /** @type {Array<Permission>} */
+ this._permissions = [];
+ /** @type {Array<HotlistIssue>} */
+ this._items = [];
+ /** @type {Array<string>} */
+ this._columns = [];
+ /** @type {?Error} */
+ this._fetchError = null;
+ /**
+ * @param {Issue} _issue
+ * @param {string} _fieldName
+ * @return {Array<string>}
+ */
+ this._extractFieldValuesFromIssue = (_issue, _fieldName) => [];
+
+ // Populated from events.
+ /** @type {Object<string, boolean>} */
+ this._filter = {Open: true};
+ /**
+ * An array of selected Issue Names.
+ * TODO(https://crbug.com/monorail/7440): Update typedef.
+ * @type {Array<string>}
+ */
+ this._selected = [];
+ }
+
+ /**
+ * @param {HotlistIssue} hotlistIssue
+ * @param {string} fieldName
+ * @return {Array<string>}
+ */
+ _extractFieldValues(hotlistIssue, fieldName) {
+ switch (fieldName) {
+ case 'Added':
+ return [relativeTime(new Date(hotlistIssue.createTime))];
+ case 'Adder':
+ return [hotlistIssue.adder.displayName];
+ case 'Rank':
+ return [String(hotlistIssue.rank + 1)];
+ default:
+ return this._extractFieldValuesFromIssue(hotlistIssue, fieldName);
+ }
+ }
+
+ /**
+ * @param {Event} e A change event fired by <chops-filter-chips>.
+ */
+ _onFilterChange(e) {
+ this._filter = e.target.selected;
+ }
+
+ /**
+ * @param {CustomEvent} e A selectionChange event fired by <mr-issue-list>.
+ */
+ _onSelectionChange(e) {
+ this._selected = e.target.selectedIssues.map(issueToName);
+ }
+
+ /** Opens a dialog to change the columns shown in the issue list. */
+ _openColumnsDialog() {
+ this.shadowRoot.querySelector('mr-change-columns').open();
+ }
+
+ /** Handles successfully saved Hotlist changes. */
+ async _handleHotlistSaveSuccess() {}
+
+ /** Removes items from the hotlist, dispatching an action to Redux. */
+ async _removeItems() {}
+
+ /** Opens a dialog to update attached Hotlists for selected Issues. */
+ _openUpdateIssuesHotlistsDialog() {
+ this.shadowRoot.querySelector('mr-update-issue-hotlists-dialog').open();
+ }
+
+ /** Opens a dialog to move selected Issues to desired Hotlist. */
+ _openMoveToHotlistDialog() {
+ this.shadowRoot.querySelector('mr-move-issue-hotlists-dialog').open();
+ }
+ /**
+ * Reranks items in the hotlist, dispatching an action to Redux.
+ * @param {Array<String>} items The names of the HotlistItems to move.
+ * @param {number} index The index to insert the moved items.
+ * @return {Promise<void>}
+ */
+ async _rerankItems(items, index) {}
+};
+
+/** Redux-connected version of _MrHotlistIssuesPage. */
+export class MrHotlistIssuesPage extends connectStore(_MrHotlistIssuesPage) {
+ /** @override */
+ stateChanged(state) {
+ this._hotlist = hotlists.viewedHotlist(state);
+ this._permissions = hotlists.viewedHotlistPermissions(state);
+ this._items = hotlists.viewedHotlistIssues(state);
+ this._columns = hotlists.viewedHotlistColumns(state);
+ this._fetchError = hotlists.requests(state).fetch.error;
+ this._extractFieldValuesFromIssue =
+ projectV0.extractFieldValuesFromIssue(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('_hotlist') && this._hotlist) {
+ const pageTitle = `Issues - ${this._hotlist.displayName}`;
+ store.dispatch(sitewide.setPageTitle(pageTitle));
+ const headerTitle = `Hotlist ${this._hotlist.displayName}`;
+ store.dispatch(sitewide.setHeaderTitle(headerTitle));
+ }
+ }
+
+ /** @override */
+ async _handleHotlistSaveSuccess() {
+ const action = hotlists.fetchItems(this._hotlist.name);
+ await store.dispatch(action);
+ store.dispatch(ui.showSnackbar(ui.snackbarNames.UPDATE_HOTLISTS_SUCCESS,
+ 'Hotlists updated.'));
+ }
+
+ /** @override */
+ async _removeItems() {
+ const action = hotlists.removeItems(this._hotlist.name, this._selected);
+ await store.dispatch(action);
+ }
+
+ /** @override */
+ async _rerankItems(items, index) {
+ // The index given from <mr-issue-list> includes only the items shown in
+ // the list and excludes the items that are being moved. So, we need to
+ // count the hidden items.
+ let shownItems = 0;
+ let hiddenItems = 0;
+ for (let i = 0; shownItems < index && i < this._items.length; ++i) {
+ const item = this._items[i];
+ const isShown = _isShown(this._filter, item);
+ if (!isShown) ++hiddenItems;
+ if (isShown && !items.includes(item.name)) ++shownItems;
+ }
+
+ await store.dispatch(hotlists.rerankItems(
+ this._hotlist.name, items, index + hiddenItems));
+ }
+};
+
+const _filterIssues = defaultMemoize(
+ /**
+ * Filters an array of HotlistIssues based on a filter condition. Memoized.
+ * @param {Object<string, boolean>} filter The types of issues to show.
+ * @param {Array<HotlistIssue>} items A HotlistIssue to check.
+ * @return {Array<HotlistIssue>}
+ */
+ (filter, items) => items.filter((item) => _isShown(filter, item)));
+
+/**
+ * Returns true iff the current filter includes the given HotlistIssue.
+ * @param {Object<string, boolean>} filter The types of issues to show.
+ * @param {HotlistIssue} item A HotlistIssue to check.
+ * @return {boolean}
+ */
+function _isShown(filter, item) {
+ return filter.Open && item.statusRef.meansOpen ||
+ filter.Closed && !item.statusRef.meansOpen;
+}
+
+customElements.define('mr-hotlist-issues-page-base', _MrHotlistIssuesPage);
+customElements.define('mr-hotlist-issues-page', MrHotlistIssuesPage);
diff --git a/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js
new file mode 100644
index 0000000..a651578
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js
@@ -0,0 +1,338 @@
+// 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 {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+import {PERMISSION_HOTLIST_EDIT} from 'shared/test/constants-permissions.js';
+
+import {MrHotlistIssuesPage} from './mr-hotlist-issues-page.js';
+
+/** @type {MrHotlistIssuesPage} */
+let element;
+
+describe('mr-hotlist-issues-page (unconnected)', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-issues-page-base');
+ element._extractFieldValuesFromIssue =
+ projectV0.extractFieldValuesFromIssue({});
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('shows hotlist fetch error', async () => {
+ element._fetchError = new Error('This is an important error');
+ element._fetchError.description = 'This is an important error';
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'important error');
+ });
+
+ it('shows loading message with null hotlist', async () => {
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'Loading');
+ });
+
+ it('renders hotlist items with one project', async () => {
+ element._hotlist = example.HOTLIST;
+ element._items = [example.HOTLIST_ISSUE];
+ await element.updateComplete;
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ assert.deepEqual(issueList.projectName, 'project-name');
+ });
+
+ it('renders hotlist items with multiple projects', async () => {
+ element._hotlist = example.HOTLIST;
+ element._items = [
+ example.HOTLIST_ISSUE,
+ example.HOTLIST_ISSUE_OTHER_PROJECT,
+ ];
+ await element.updateComplete;
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ assert.isNull(issueList.projectName);
+ });
+
+ it('needs permissions to rerank', async () => {
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ assert.isNull(issueList.rerank);
+
+ element._permissions = [hotlists.EDIT];
+ await element.updateComplete;
+
+ assert.isNotNull(issueList.rerank);
+ });
+
+ it('memoizes issues', async () => {
+ element._hotlist = example.HOTLIST;
+ element._items = [example.HOTLIST_ISSUE];
+ await element.updateComplete;
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ const issues = issueList.issues;
+
+ // Trigger a render without updating the issue list.
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ assert.strictEqual(issues, issueList.issues);
+
+ // Modify the issue list.
+ element._items = [example.HOTLIST_ISSUE];
+ await element.updateComplete;
+
+ assert.notStrictEqual(issues, issueList.issues);
+ });
+
+ it('computes strings for HotlistIssue fields', async () => {
+ const clock = sinon.useFakeTimers(24 * 60 * 60 * 1000);
+
+ try {
+ element._hotlist = example.HOTLIST;
+ element._items = [{
+ ...example.HOTLIST_ISSUE,
+ summary: 'Summary',
+ rank: 52,
+ adder: exampleUsers.USER,
+ createTime: new Date(0).toISOString(),
+ }];
+ element._columns = ['Summary', 'Rank', 'Added', 'Adder'];
+ await element.updateComplete;
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ assert.include(issueList.shadowRoot.innerHTML, 'Summary');
+ assert.include(issueList.shadowRoot.innerHTML, '53');
+ assert.include(issueList.shadowRoot.innerHTML, 'a day ago');
+ assert.include(issueList.shadowRoot.innerHTML, exampleUsers.DISPLAY_NAME);
+ } finally {
+ clock.restore();
+ }
+ });
+
+ it('filters and shows closed issues', async () => {
+ element._hotlist = example.HOTLIST;
+ element._items = [example.HOTLIST_ISSUE_CLOSED];
+ await element.updateComplete;
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ assert.equal(issueList.issues.length, 0);
+
+ element.shadowRoot.querySelector('chops-filter-chips').select('Closed');
+ await element.updateComplete;
+
+ assert.isTrue(element._filter.Closed);
+ assert.equal(issueList.issues.length, 1);
+ });
+
+ it('updates button bar on list selection', async () => {
+ element._permissions = PERMISSION_HOTLIST_EDIT;
+ element._hotlist = example.HOTLIST;
+ element._items = [example.HOTLIST_ISSUE];
+ await element.updateComplete;
+
+ const buttonBar = element.shadowRoot.querySelector('mr-button-bar');
+ assert.include(buttonBar.shadowRoot.innerHTML, 'Change columns');
+ assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Remove');
+ assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Update');
+ assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Move to...');
+ assert.deepEqual(element._selected, []);
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ issueList.shadowRoot.querySelector('input').click();
+ await element.updateComplete;
+
+ assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Change columns');
+ assert.include(buttonBar.shadowRoot.innerHTML, 'Remove');
+ assert.include(buttonBar.shadowRoot.innerHTML, 'Update');
+ assert.include(buttonBar.shadowRoot.innerHTML, 'Move to...');
+ assert.deepEqual(element._selected, [exampleIssues.NAME]);
+ });
+
+ it('hides issues checkboxes if the user cannot edit', async () => {
+ element._permissions = [];
+ element._hotlist = example.HOTLIST;
+ element._items = [example.HOTLIST_ISSUE];
+ await element.updateComplete;
+
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+ assert.notInclude(issueList.shadowRoot.innerHTML, 'input');
+ });
+
+ it('opens "Change columns" dialog', async () => {
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector('mr-change-columns');
+ sinon.stub(dialog, 'open');
+ try {
+ element._openColumnsDialog();
+
+ sinon.assert.calledOnce(dialog.open);
+ } finally {
+ dialog.open.restore();
+ }
+ });
+
+ it('opens "Update" dialog', async () => {
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector(
+ 'mr-update-issue-hotlists-dialog');
+ sinon.stub(dialog, 'open');
+ try {
+ element._openUpdateIssuesHotlistsDialog();
+
+ sinon.assert.calledOnce(dialog.open);
+ } finally {
+ dialog.open.restore();
+ }
+ });
+
+ it('handles successful save from its update dialog', async () => {
+ sinon.stub(element, '_handleHotlistSaveSuccess');
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ try {
+ const dialog =
+ element.shadowRoot.querySelector('mr-update-issue-hotlists-dialog');
+ dialog.dispatchEvent(new Event('saveSuccess'));
+ sinon.assert.calledOnce(element._handleHotlistSaveSuccess);
+ } finally {
+ element._handleHotlistSaveSuccess.restore();
+ }
+ });
+
+ it('opens "Move to..." dialog', async () => {
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector(
+ 'mr-move-issue-hotlists-dialog');
+ sinon.stub(dialog, 'open');
+ try {
+ element._openMoveToHotlistDialog();
+
+ sinon.assert.calledOnce(dialog.open);
+ } finally {
+ dialog.open.restore();
+ }
+ });
+
+ it('handles successful save from its move dialog', async () => {
+ sinon.stub(element, '_handleHotlistSaveSuccess');
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ try {
+ const dialog =
+ element.shadowRoot.querySelector('mr-move-issue-hotlists-dialog');
+ dialog.dispatchEvent(new Event('saveSuccess'));
+ sinon.assert.calledOnce(element._handleHotlistSaveSuccess);
+ } finally {
+ element._handleHotlistSaveSuccess.restore();
+ }
+ });
+});
+
+describe('mr-hotlist-issues-page (connected)', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-issues-page');
+ element._extractFieldValuesFromIssue =
+ projectV0.extractFieldValuesFromIssue({});
+ document.body.appendChild(element);
+
+ // Stop Redux from overriding values being tested.
+ sinon.stub(element, 'stateChanged');
+ });
+
+ afterEach(() => {
+ element.stateChanged.restore();
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrHotlistIssuesPage);
+ });
+
+ it('updates page title and header', async () => {
+ element._hotlist = {...example.HOTLIST, displayName: 'Hotlist-Name'};
+ await element.updateComplete;
+
+ const state = store.getState();
+ assert.deepEqual(sitewide.pageTitle(state), 'Issues - Hotlist-Name');
+ assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+ });
+
+ it('removes items', () => {
+ element._hotlist = example.HOTLIST;
+ element._selected = [exampleIssues.NAME];
+
+ const removeItems = sinon.spy(hotlists, 'removeItems');
+ try {
+ element._removeItems();
+ sinon.assert.calledWith(removeItems, example.NAME, [exampleIssues.NAME]);
+ } finally {
+ removeItems.restore();
+ }
+ });
+
+ it('fetches a hotlist when handling a successful save', () => {
+ element._hotlist = example.HOTLIST;
+
+ const fetchItems = sinon.spy(hotlists, 'fetchItems');
+ try {
+ element._handleHotlistSaveSuccess();
+ sinon.assert.calledWith(fetchItems, example.NAME);
+ } finally {
+ fetchItems.restore();
+ }
+ });
+
+ it('reranks', () => {
+ element._hotlist = example.HOTLIST;
+ element._items = [
+ example.HOTLIST_ISSUE,
+ example.HOTLIST_ISSUE_CLOSED,
+ example.HOTLIST_ISSUE_OTHER_PROJECT,
+ ];
+
+ const rerankItems = sinon.spy(hotlists, 'rerankItems');
+ try {
+ element._rerankItems([example.HOTLIST_ITEM_NAME], 1);
+
+ sinon.assert.calledWith(
+ rerankItems, example.NAME, [example.HOTLIST_ITEM_NAME], 2);
+ } finally {
+ rerankItems.restore();
+ }
+ });
+});
+
+it('mr-hotlist-issues-page (stateChanged)', () => {
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-issues-page');
+ document.body.appendChild(element);
+ assert.instanceOf(element, MrHotlistIssuesPage);
+ document.body.removeChild(element);
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js
new file mode 100644
index 0000000..c317d39
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js
@@ -0,0 +1,260 @@
+// 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 debounce from 'debounce';
+import {LitElement, html, css} from 'lit-element';
+
+import {userV3ToRef} from 'shared/convertersV0.js';
+
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as users from 'reducers/users.js';
+
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+/** Hotlist People page */
+class _MrHotlistPeoplePage extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ }
+ section {
+ margin: 16px 24px;
+ }
+ h2 {
+ font-weight: normal;
+ }
+
+ ul {
+ padding: 0;
+ }
+ li {
+ list-style-type: none;
+ }
+ p, li, form {
+ display: flex;
+ }
+ p, ul, li, form {
+ margin: 12px 0;
+ }
+
+ input {
+ margin-left: -6px;
+ padding: 4px;
+ width: 320px;
+ }
+
+ button {
+ align-items: center;
+ background-color: transparent;
+ border: 0;
+ cursor: pointer;
+ display: inline-flex;
+ margin: 0 4px;
+ padding: 0;
+ }
+ .material-icons {
+ font-size: 18px;
+ }
+
+ .placeholder::before {
+ animation: pulse 1s infinite ease-in-out;
+ border-radius: 3px;
+ content: " ";
+ height: 10px;
+ margin: 4px 0;
+ width: 200px;
+ }
+ @keyframes pulse {
+ 0% {background-color: var(--chops-blue-50);}
+ 50% {background-color: var(--chops-blue-75);}
+ 100% {background-color: var(--chops-blue-50);}
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <mr-hotlist-header selected=1></mr-hotlist-header>
+ ${this._renderPage()}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderPage() {
+ if (this._fetchError) {
+ return html`<section>${this._fetchError.description}</section>`;
+ }
+
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+
+ <section>
+ <h2>Owner</h2>
+ ${this._renderOwner(this._owner)}
+ </section>
+
+ <section>
+ <h2>Editors</h2>
+ ${this._renderEditors(this._editors)}
+
+ ${this._permissions.includes(hotlists.ADMINISTER) ? html`
+ <form @submit=${this._onAddEditors}>
+ <input id="add" placeholder="List of email addresses"></input>
+ <button><i class="material-icons">add</i></button>
+ </form>
+ ` : html``}
+ </section>
+ `;
+ }
+
+ /**
+ * @param {?User} owner
+ * @return {TemplateResult}
+ */
+ _renderOwner(owner) {
+ if (!owner) return html`<p class="placeholder"></p>`;
+ return html`
+ <p><mr-user-link .userRef=${userV3ToRef(owner)}></mr-user-link></p>
+ `;
+ }
+
+ /**
+ * @param {?Array<User>} editors
+ * @return {TemplateResult}
+ */
+ _renderEditors(editors) {
+ if (!editors) return html`<p class="placeholder"></p>`;
+ if (!editors.length) return html`<p>No editors.</p>`;
+
+ return html`
+ <ul>${editors.map((editor) => this._renderEditor(editor))}</ul>
+ `;
+ }
+
+ /**
+ * @param {?User} editor
+ * @return {TemplateResult}
+ */
+ _renderEditor(editor) {
+ if (!editor) return html`<li class="placeholder"></li>`;
+
+ const canRemove = this._permissions.includes(hotlists.ADMINISTER) ||
+ editor.name === this._currentUserName;
+
+ return html`
+ <li>
+ <mr-user-link .userRef=${userV3ToRef(editor)}></mr-user-link>
+ ${canRemove ? html`
+ <button @click=${this._removeEditor.bind(this, editor.name)}>
+ <i class="material-icons">clear</i>
+ </button>
+ ` : html``}
+ </li>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ // Populated from Redux.
+ _hotlist: {type: Object},
+ _owner: {type: Object},
+ _editors: {type: Array},
+ _permissions: {type: Array},
+ _currentUserName: {type: String},
+ _fetchError: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ // Populated from Redux.
+ /** @type {?Hotlist} */ this._hotlist = null;
+ /** @type {?User} */ this._owner = null;
+ /** @type {Array<User>} */ this._editors = null;
+ /** @type {Array<Permission>} */ this._permissions = [];
+ /** @type {?String} */ this._currentUserName = null;
+ /** @type {?Error} */ this._fetchError = null;
+
+ this._debouncedAddEditors = debounce(this._addEditors, 400, true);
+ }
+
+ /** Adds hotlist editors.
+ * @param {Event} event
+ */
+ async _onAddEditors(event) {
+ event.preventDefault();
+
+ const input =
+ /** @type {HTMLInputElement} */ (this.shadowRoot.getElementById('add'));
+ const emails = input.value.split(/[\s,;]/).filter((e) => e);
+ if (!emails.length) return;
+ const editors = emails.map((email) => 'users/' + email);
+ try {
+ await this._debouncedAddEditors(editors);
+ input.value = '';
+ } catch (error) {
+ // The `hotlists.update()` call shows a snackbar on errors.
+ }
+ }
+
+ /** Adds hotlist editors.
+ * @param {Array<string>} editors An Array of User resource names.
+ */
+ async _addEditors(editors) {}
+
+ /**
+ * Removes a hotlist editor.
+ * @param {string} name A User resource name.
+ */
+ async _removeEditor(name) {}
+};
+
+/** Redux-connected version of _MrHotlistPeoplePage. */
+export class MrHotlistPeoplePage extends connectStore(_MrHotlistPeoplePage) {
+ /** @override */
+ stateChanged(state) {
+ this._hotlist = hotlists.viewedHotlist(state);
+ this._owner = hotlists.viewedHotlistOwner(state);
+ this._editors = hotlists.viewedHotlistEditors(state);
+ this._permissions = hotlists.viewedHotlistPermissions(state);
+ this._currentUserName = users.currentUserName(state);
+ this._fetchError = hotlists.requests(state).fetch.error;
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('_hotlist') && this._hotlist) {
+ const pageTitle = 'People - ' + this._hotlist.displayName;
+ store.dispatch(sitewide.setPageTitle(pageTitle));
+ const headerTitle = 'Hotlist ' + this._hotlist.displayName;
+ store.dispatch(sitewide.setHeaderTitle(headerTitle));
+ }
+ }
+
+ /** @override */
+ async _addEditors(editors) {
+ await store.dispatch(hotlists.update(this._hotlist.name, {editors}));
+ }
+
+ /** @override */
+ async _removeEditor(name) {
+ await store.dispatch(hotlists.removeEditors(this._hotlist.name, [name]));
+ }
+}
+
+customElements.define('mr-hotlist-people-page-base', _MrHotlistPeoplePage);
+customElements.define('mr-hotlist-people-page', MrHotlistPeoplePage);
diff --git a/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js
new file mode 100644
index 0000000..b7dd6dc
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js
@@ -0,0 +1,176 @@
+// Copyright 2020 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 {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {MrHotlistPeoplePage} from './mr-hotlist-people-page.js';
+
+/** @type {MrHotlistPeoplePage} */
+let element;
+
+describe('mr-hotlist-people-page (unconnected)', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-people-page-base');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('shows hotlist fetch error', async () => {
+ element._fetchError = new Error('This is an important error');
+ element._fetchError.description = 'This is an important error';
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'important error');
+ });
+
+ it('renders placeholders with no data', async () => {
+ await element.updateComplete;
+
+ const placeholders = element.shadowRoot.querySelectorAll('.placeholder');
+ assert.equal(placeholders.length, 2);
+ });
+
+ it('renders placeholders with editors list but no user data', async () => {
+ element._editors = [null, null];
+ await element.updateComplete;
+
+ const placeholders = element.shadowRoot.querySelectorAll('.placeholder');
+ assert.equal(placeholders.length, 3);
+ });
+
+ it('renders "No editors"', async () => {
+ element._editors = [];
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.innerHTML, 'No editors');
+ });
+
+ it('renders hotlist', async () => {
+ element._hotlist = example.HOTLIST;
+ element._owner = exampleUsers.USER;
+ element._editors = [exampleUsers.USER_2];
+ await element.updateComplete;
+ });
+
+ it('shows controls iff user has admin permissions', async () => {
+ element._editors = [exampleUsers.USER_2];
+ await element.updateComplete;
+
+ assert.equal(element.shadowRoot.querySelectorAll('button').length, 0);
+
+ element._permissions = [hotlists.ADMINISTER];
+ await element.updateComplete;
+
+ assert.equal(element.shadowRoot.querySelectorAll('button').length, 2);
+ });
+
+ it('shows remove button if user is editing themselves', async () => {
+ element._editors = [exampleUsers.USER, exampleUsers.USER_2];
+ element._currentUserName = exampleUsers.USER_2.name;
+ await element.updateComplete;
+
+ assert.equal(element.shadowRoot.querySelectorAll('button').length, 1);
+ });
+});
+
+describe('mr-hotlist-people-page (connected)', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-people-page');
+ document.body.appendChild(element);
+
+ // Stop Redux from overriding values being tested.
+ sinon.stub(element, 'stateChanged');
+ });
+
+ afterEach(() => {
+ element.stateChanged.restore();
+ document.body.removeChild(element);
+ });
+
+ it('initializes', async () => {
+ assert.instanceOf(element, MrHotlistPeoplePage);
+ });
+
+ it('updates page title and header', async () => {
+ element._hotlist = {...example.HOTLIST, displayName: 'Hotlist-Name'};
+ await element.updateComplete;
+
+ const state = store.getState();
+ assert.deepEqual(sitewide.pageTitle(state), 'People - Hotlist-Name');
+ assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+ });
+
+ it('adds editors', async () => {
+ element._hotlist = example.HOTLIST;
+ element._permissions = [hotlists.ADMINISTER];
+ await element.updateComplete;
+
+ const input = /** @type {HTMLInputElement} */
+ (element.shadowRoot.getElementById('add'));
+ input.value = 'test@example.com, test2@example.com';
+
+ const update = sinon.spy(hotlists, 'update');
+ try {
+ await element._onAddEditors(new Event('submit'));
+
+ const editors = ['users/test@example.com', 'users/test2@example.com'];
+ sinon.assert.calledWith(update, example.HOTLIST.name, {editors});
+ } finally {
+ update.restore();
+ }
+ });
+
+ it('_onAddEditors ignores empty input', async () => {
+ element._permissions = [hotlists.ADMINISTER];
+ await element.updateComplete;
+
+ const input = /** @type {HTMLInputElement} */
+ (element.shadowRoot.getElementById('add'));
+ input.value = ' ';
+
+ const update = sinon.spy(hotlists, 'update');
+ try {
+ await element._onAddEditors(new Event('submit'));
+ sinon.assert.notCalled(update);
+ } finally {
+ update.restore();
+ }
+ });
+
+ it('removes editors', async () => {
+ element._hotlist = example.HOTLIST;
+
+ const removeEditors = sinon.spy(hotlists, 'removeEditors');
+ try {
+ await element._removeEditor(exampleUsers.NAME_2);
+
+ sinon.assert.calledWith(
+ removeEditors, example.HOTLIST.name, [exampleUsers.NAME_2]);
+ } finally {
+ removeEditors.restore();
+ }
+ });
+});
+
+it('mr-hotlist-people-page (stateChanged)', () => {
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-people-page');
+ document.body.appendChild(element);
+ assert.instanceOf(element, MrHotlistPeoplePage);
+ document.body.removeChild(element);
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js
new file mode 100644
index 0000000..4f4d90d
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js
@@ -0,0 +1,310 @@
+// 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 page from 'page';
+import 'shared/typedef.js';
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as ui from 'reducers/ui.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+/**
+ * Supported Hotlist privacy options from feature_objects.proto.
+ * @enum {string}
+ */
+const HotlistPrivacy = {
+ PRIVATE: 'PRIVATE',
+ PUBLIC: 'PUBLIC',
+};
+
+/** Hotlist Settings page */
+class _MrHotlistSettingsPage extends LitElement {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: block;
+ }
+ h2 {
+ font-weight: normal;
+ }
+ section, dl, form {
+ margin: 16px 24px;
+ }
+ dt {
+ font-weight: bold;
+ text-align: right;
+ word-wrap: break-word;
+ }
+ dd {
+ margin-left: 0;
+ }
+ label {
+ display: flex;
+ flex-direction: column;
+ }
+ form input,
+ form select {
+ /* Match minimum size of header. */
+ min-width: 250px;
+ }
+ /* https://material.io/design/layout/responsive-layout-grid.html#breakpoints */
+ @media (min-width: 1024px) {
+ input,
+ select,
+ p,
+ dd {
+ max-width: 750px;
+ }
+ }
+ #save-hotlist {
+ background: var(--chops-primary-button-bg);
+ color: var(--chops-primary-button-color);
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <mr-hotlist-header selected=2></mr-hotlist-header>
+ ${this._renderPage()}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderPage() {
+ if (!this._hotlist) {
+ if (this._fetchError) {
+ return html`<section>${this._fetchError.description}</section>`;
+ } else {
+ return html`<section>Loading...</section>`;
+ }
+ }
+
+ const defaultColumns = this._hotlist.defaultColumns
+ .map((col) => col.column).join(' ');
+ if (this._permissions.includes(hotlists.ADMINISTER)) {
+ return this._renderEditableForm(defaultColumns);
+ }
+ return this._renderViewOnly(defaultColumns);
+ }
+
+ /**
+ * Render the editable form Settings page.
+ * @param {string} defaultColumns The default columns to be shown.
+ * @return {TemplateResult}
+ */
+ _renderEditableForm(defaultColumns) {
+ return html`
+ <form id="settingsForm" class="input-grid"
+ @change=${this._handleFormChange}>
+ <label>Name</label>
+ <input id="displayName" class="path"
+ value="${this._hotlist.displayName}">
+ <label>Summary</label>
+ <input id="summary" class="path" value="${this._hotlist.summary}">
+ <label>Default Issues columns</label>
+ <input id="defaultColumns" class="path" value="${defaultColumns}">
+ <label>Who can view this hotlist</label>
+ <select id="hotlistPrivacy" class="path">
+ <option
+ value="${HotlistPrivacy.PUBLIC}"
+ ?selected="${this._hotlist.hotlistPrivacy ===
+ HotlistPrivacy.PUBLIC}">
+ Anyone on the Internet
+ </option>
+ <option
+ value="${HotlistPrivacy.PRIVATE}"
+ ?selected="${this._hotlist.hotlistPrivacy ===
+ HotlistPrivacy.PRIVATE}">
+ Members only
+ </option>
+ </select>
+ <span><!-- grid spacer --></span>
+ <p>
+ Individual issues in the list can only be seen by users who can
+ normally see them. The privacy status of an issue is considered
+ when it is being displayed (or not displayed) in a hotlist.
+ </p>
+ <span><!-- grid spacer --></span>
+ <div>
+ <chops-button @click=${this._save} id="save-hotlist" disabled>
+ Save hotlist
+ </chops-button>
+ <chops-button @click=${this._delete} id="delete-hotlist">
+ Delete hotlist
+ </chops-button>
+ </div>
+ </form>
+ `;
+ }
+
+ /**
+ * Render the view-only Settings page.
+ * @param {string} defaultColumns The default columns to be shown.
+ * @return {TemplateResult}
+ */
+ _renderViewOnly(defaultColumns) {
+ return html`
+ <dl class="input-grid">
+ <dt>Name</dt>
+ <dd>${this._hotlist.displayName}</dd>
+ <dt>Summary</dt>
+ <dd>${this._hotlist.summary}</dd>
+ <dt>Default Issues columns</dt>
+ <dd>${defaultColumns}</dd>
+ <dt>Who can view this hotlist</dt>
+ <dd>
+ ${this._hotlist.hotlistPrivacy &&
+ this._hotlist.hotlistPrivacy === HotlistPrivacy.PUBLIC ?
+ 'Anyone on the Internet' : 'Members only'}
+ </dd>
+ <dt></dt>
+ <dd>
+ Individual issues in the list can only be seen by users who can
+ normally see them. The privacy status of an issue is considered
+ when it is being displayed (or not displayed) in a hotlist.
+ </dd>
+ </dl>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ // Populated from Redux.
+ _hotlist: {type: Object},
+ _permissions: {type: Array},
+ _currentUser: {type: Object},
+ _fetchError: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ // Populated from Redux.
+ /** @type {?Hotlist} */ this._hotlist = null;
+ /** @type {Array<Permission>} */ this._permissions = [];
+ /** @type {UserRef} */ this._currentUser = null;
+ /** @type {?Error} */ this._fetchError = null;
+
+ // Expose page.js for test stubbing.
+ this.page = page;
+ }
+
+ /**
+ * Handles changes to the editable form.
+ * @param {Event} e
+ */
+ _handleFormChange() {
+ const saveButton = this.shadowRoot.getElementById('save-hotlist');
+ if (saveButton.disabled) {
+ saveButton.disabled = false;
+ }
+ }
+
+ /** Saves the hotlist, dispatching an action to Redux. */
+ async _save() {}
+
+ /** Deletes the hotlist, dispatching an action to Redux. */
+ async _delete() {}
+};
+
+/** Redux-connected version of _MrHotlistSettingsPage. */
+export class MrHotlistSettingsPage
+ extends connectStore(_MrHotlistSettingsPage) {
+ /** @override */
+ stateChanged(state) {
+ this._hotlist = hotlists.viewedHotlist(state);
+ this._permissions = hotlists.viewedHotlistPermissions(state);
+ this._currentUser = userV0.currentUser(state);
+ this._fetchError = hotlists.requests(state).fetch.error;
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('_hotlist') && this._hotlist) {
+ const pageTitle = 'Settings - ' + this._hotlist.displayName;
+ store.dispatch(sitewide.setPageTitle(pageTitle));
+ const headerTitle = 'Hotlist ' + this._hotlist.displayName;
+ store.dispatch(sitewide.setHeaderTitle(headerTitle));
+ }
+ }
+
+ /** @override */
+ async _save() {
+ const form = this.shadowRoot.getElementById('settingsForm');
+ if (!form) return;
+
+ // TODO(https://crbug.com/monorail/7475): Consider generalizing this logic.
+ const updatedHotlist = /** @type {Hotlist} */({});
+ // These are is an input or select elements.
+ const pathInputs = form.querySelectorAll('.path');
+ pathInputs.forEach((input) => {
+ const path = input.id;
+ const value = /** @type {HTMLInputElement} */(input).value;
+ switch (path) {
+ case 'defaultColumns':
+ const columnsValue = [];
+ value.trim().split(' ').forEach((column) => {
+ if (column) columnsValue.push({column});
+ });
+ if (JSON.stringify(columnsValue) !==
+ JSON.stringify(this._hotlist.defaultColumns)) {
+ updatedHotlist.defaultColumns = columnsValue;
+ }
+ break;
+ default:
+ if (value !== this._hotlist[path]) updatedHotlist[path] = value;
+ break;
+ };
+ });
+
+ const action = hotlists.update(this._hotlist.name, updatedHotlist);
+ await store.dispatch(action);
+ this._showHotlistSavedSnackbar();
+ }
+
+ /**
+ * Shows a snackbar informing the user about their save request.
+ */
+ async _showHotlistSavedSnackbar() {
+ await store.dispatch(ui.showSnackbar(
+ 'SNACKBAR_ID_HOTLIST_SETTINGS_UPDATED', 'Hotlist Updated.'));
+ }
+
+ /** @override */
+ async _delete() {
+ if (confirm(
+ 'Are you sure you want to delete this hotlist? This cannot be undone.')
+ ) {
+ const action = hotlists.deleteHotlist(this._hotlist.name);
+ await store.dispatch(action);
+
+ // TODO(crbug/monorail/7430): Handle an error and add <chops-snackbar>.
+ // Note that this will redirect regardless of an error.
+ this.page(`/u/${this._currentUser.displayName}/hotlists`);
+ }
+ }
+}
+
+customElements.define('mr-hotlist-settings-page-base', _MrHotlistSettingsPage);
+customElements.define('mr-hotlist-settings-page', MrHotlistSettingsPage);
diff --git a/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js
new file mode 100644
index 0000000..987fff2
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.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 sinon from 'sinon';
+
+import {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {MrHotlistSettingsPage} from './mr-hotlist-settings-page.js';
+
+/** @type {MrHotlistSettingsPage} */
+let element;
+
+describe('mr-hotlist-settings-page (unconnected)', () => {
+ beforeEach(() => {
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-settings-page-base');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('shows hotlist fetch error', async () => {
+ element._fetchError = new Error('This is an important error');
+ element._fetchError.description = 'This is an important error';
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'important error');
+ });
+
+ it('shows loading message with null hotlist', async () => {
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'Loading');
+ });
+
+ it('renders hotlist', async () => {
+ element._hotlist = example.HOTLIST;
+ await element.updateComplete;
+ });
+
+ it('renders a view only hotlist if no permissions', async () => {
+ element._hotlist = {...example.HOTLIST};
+ await element.updateComplete;
+ assert.notInclude(element.shadowRoot.innerHTML, 'form');
+ });
+
+ it('renders an editable hotlist if permission to administer', async () => {
+ element._hotlist = {...example.HOTLIST};
+ element._permissions = [hotlists.ADMINISTER];
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'form');
+ });
+
+ it('renders private hotlist', async () => {
+ element._hotlist = {...example.HOTLIST, hotlistPrivacy: 'PRIVATE'};
+ await element.updateComplete;
+ assert.include(element.shadowRoot.innerHTML, 'Members only');
+ });
+});
+
+describe('mr-hotlist-settings-page (connected)', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-settings-page');
+ document.body.appendChild(element);
+
+ // Stop Redux from overriding values being tested.
+ sinon.stub(element, 'stateChanged');
+ });
+
+ afterEach(() => {
+ element.stateChanged.restore();
+ document.body.removeChild(element);
+ });
+
+ it('updates page title and header', async () => {
+ element._hotlist = {...example.HOTLIST, displayName: 'Hotlist-Name'};
+ await element.updateComplete;
+
+ const state = store.getState();
+ assert.deepEqual(sitewide.pageTitle(state), 'Settings - Hotlist-Name');
+ assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+ });
+
+ it('deletes hotlist', async () => {
+ element._hotlist = example.HOTLIST;
+ element._permissions = [hotlists.ADMINISTER];
+ element._currentUser = exampleUsers.USER;
+ await element.updateComplete;
+
+ const deleteButton = element.shadowRoot.getElementById('delete-hotlist');
+ assert.isNotNull(deleteButton);
+
+ // Auto confirm deletion of hotlist.
+ const confirmStub = sinon.stub(window, 'confirm');
+ confirmStub.returns(true);
+
+ const pageStub = sinon.stub(element, 'page');
+
+ const deleteHotlist = sinon.spy(hotlists, 'deleteHotlist');
+
+ try {
+ await element._delete();
+
+ sinon.assert.calledWith(deleteHotlist, example.NAME);
+ sinon.assert.calledWith(
+ element.page, `/u/${exampleUsers.DISPLAY_NAME}/hotlists`);
+ } finally {
+ deleteHotlist.restore();
+ pageStub.restore();
+ confirmStub.restore();
+ }
+ });
+
+ it('updates hotlist when there are changes', async () => {
+ element._hotlist = {...example.HOTLIST};
+ element._permissions = [hotlists.ADMINISTER];
+ await element.updateComplete;
+
+ const saveButton = element.shadowRoot.getElementById('save-hotlist');
+ assert.isNotNull(saveButton);
+ assert.isTrue(saveButton.hasAttribute('disabled'));
+
+ const hlist = {
+ displayName: element._hotlist.displayName + 'foo',
+ summary: element._hotlist.summary + 'abc',
+ };
+
+ const summaryInput = element.shadowRoot.getElementById('summary');
+ /** @type {HTMLInputElement} */ (summaryInput).value += 'abc';
+ const nameInput =
+ element.shadowRoot.getElementById('displayName');
+ /** @type {HTMLInputElement} */ (nameInput).value += 'foo';
+
+ await element.shadowRoot.getElementById('settingsForm').dispatchEvent(
+ new Event('change'));
+ assert.isFalse(saveButton.hasAttribute('disabled'));
+
+ const snackbarStub = sinon.stub(element, '_showHotlistSavedSnackbar');
+ const update = sinon.stub(hotlists, 'update').returns(async () => {});
+ try {
+ await element._save();
+ sinon.assert.calledWith(update, example.HOTLIST.name, hlist);
+ sinon.assert.calledOnce(snackbarStub);
+ } finally {
+ update.restore();
+ snackbarStub.restore();
+ }
+ });
+});
+
+it('mr-hotlist-settings-page (stateChanged)', () => {
+ // @ts-ignore
+ element = document.createElement('mr-hotlist-settings-page');
+ document.body.appendChild(element);
+ assert.instanceOf(element, MrHotlistSettingsPage);
+ document.body.removeChild(element);
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js
new file mode 100644
index 0000000..8da3083
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js
@@ -0,0 +1,186 @@
+// 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 {store, connectStore} from 'reducers/base.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 'elements/framework/mr-error/mr-error.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+// TODO(zhangtiff): Make dialog components subclass chops-dialog instead of
+// using slots/containment once we switch to LitElement.
+/**
+ * `<mr-convert-issue>`
+ *
+ * This allows a user to update the structure of an issue to that of
+ * a chosen project template.
+ *
+ */
+export class MrConvertIssue extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ label {
+ font-weight: bold;
+ text-align: right;
+ }
+ form {
+ padding: 1em 8px;
+ display: block;
+ font-size: var(--chops-main-font-size);
+ }
+ textarea {
+ font-family: var(--mr-toggled-font-family);
+ min-height: 80px;
+ border: var(--chops-accessible-border);
+ padding: 0.5em 4px;
+ }
+ .edit-actions {
+ width: 100%;
+ margin: 0.5em 0;
+ text-align: right;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <chops-dialog closeOnOutsideClick>
+ <h3 class="medium-heading">Convert issue to new template structure</h3>
+ <form id="convertIssueForm">
+ <div class="input-grid">
+ <label for="templateInput">Pick a template: </label>
+ <select id="templateInput" @change=${this._templateInputChanged}>
+ <option value="">--Please choose a project template--</option>
+ ${this.projectTemplates.map((projTempl) => html`
+ <option value=${projTempl.templateName}>
+ ${projTempl.templateName}
+ </option>`)}
+ </select>
+ <label for="commentContent">Comment: </label>
+ <textarea id="commentContent" placeholder="Add a comment"></textarea>
+ <span></span>
+ <chops-checkbox
+ @checked-change=${this._sendEmailChecked}
+ checked=${this.sendEmail}
+ >Send email</chops-checkbox>
+ </div>
+ <mr-error ?hidden=${!this.convertIssueError}>
+ ${this.convertIssueError && this.convertIssueError.description}
+ </mr-error>
+ <div class="edit-actions">
+ <chops-button @click=${this.close} class="de-emphasized discard-button">
+ Discard
+ </chops-button>
+ <chops-button @click=${this.save} class="emphasized" ?disabled=${!this.selectedTemplate}>
+ Convert issue
+ </chops-button>
+ </div>
+ </form>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ convertingIssue: {
+ type: Boolean,
+ },
+ convertIssueError: {
+ type: Object,
+ },
+ issuePermissions: {
+ type: Object,
+ },
+ issueRef: {
+ type: Object,
+ },
+ projectTemplates: {
+ type: Array,
+ },
+ selectedTemplate: {
+ type: String,
+ },
+ sendEmail: {
+ type: Boolean,
+ },
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.convertingIssue = issueV0.requests(state).convert.requesting;
+ this.convertIssueError = issueV0.requests(state).convert.error;
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.issuePermissions = issueV0.permissions(state);
+ this.projectTemplates = projectV0.viewedTemplates(state);
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.selectedTemplate = '';
+ this.sendEmail = true;
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('convertingIssue')) {
+ if (!this.convertingIssue && !this.convertIssueError) {
+ this.close();
+ }
+ }
+ }
+
+ open() {
+ this.reset();
+ const dialog = this.shadowRoot.querySelector('chops-dialog');
+ dialog.open();
+ }
+
+ close() {
+ const dialog = this.shadowRoot.querySelector('chops-dialog');
+ dialog.close();
+ }
+
+ /**
+ * Resets the user's input.
+ */
+ reset() {
+ this.shadowRoot.querySelector('#convertIssueForm').reset();
+ }
+
+ /**
+ * Dispatches a Redux action to convert the issue to a new template.
+ */
+ save() {
+ const commentContent = this.shadowRoot.querySelector('#commentContent');
+ store.dispatch(issueV0.convert(this.issueRef, {
+ templateName: this.selectedTemplate,
+ commentContent: commentContent.value,
+ sendEmail: this.sendEmail,
+ }));
+ }
+
+ _sendEmailChecked(evt) {
+ this.sendEmail = evt.detail.checked;
+ }
+
+ _templateInputChanged() {
+ this.selectedTemplate = this.shadowRoot.querySelector(
+ '#templateInput').value;
+ }
+}
+
+customElements.define('mr-convert-issue', MrConvertIssue);
diff --git a/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js
new file mode 100644
index 0000000..b68e274
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.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 {MrConvertIssue} from './mr-convert-issue.js';
+
+let element;
+
+describe('mr-convert-issue', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-convert-issue');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrConvertIssue);
+ });
+
+ it('no template chosen', async () => {
+ await element.updateComplete;
+
+ const buttons = element.shadowRoot.querySelectorAll('chops-button');
+ assert.isTrue(buttons[buttons.length - 1].disabled);
+ });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js
new file mode 100644
index 0000000..2a34b8f
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js
@@ -0,0 +1,340 @@
+// 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/framework/mr-upload/mr-upload.js';
+import 'elements/framework/mr-error/mr-error.js';
+import {fieldTypes} from 'shared/issue-fields.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 'elements/chops/chops-checkbox/chops-checkbox.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES, MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+
+
+/**
+ * `<mr-edit-description>`
+ *
+ * A dialog to edit descriptions.
+ *
+ */
+export class MrEditDescription extends connectStore(LitElement) {
+ /** @override */
+ constructor() {
+ super();
+ this._editedDescription = '';
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ MD_PREVIEW_STYLES,
+ MD_STYLES,
+ css`
+ chops-dialog {
+ --chops-dialog-width: 800px;
+ }
+ textarea {
+ font-family: var(--mr-toggled-font-family);
+ min-height: 300px;
+ max-height: 500px;
+ border: var(--chops-accessible-border);
+ padding: 0.5em 4px;
+ margin: 0.5em 0;
+ }
+ .attachments {
+ margin: 0.5em 0;
+ }
+ .content {
+ padding: 0.5em 0.5em;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ .edit-controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <chops-dialog aria-labelledby="editDialogTitle">
+ <h3 id="editDialogTitle" class="medium-heading">
+ Edit ${this._title}
+ </h3>
+ <textarea
+ id="description"
+ class="content"
+ @keyup=${this._setEditedDescription}
+ @change=${this._setEditedDescription}
+ .value=${this._editedDescription}
+ ></textarea>
+ ${this._renderMarkdown ? html`
+ <div class="markdown-preview preview-height-description">
+ <div class="markdown">
+ ${unsafeHTML(renderMarkdown(this._editedDescription))}
+ </div>
+ </div>`: ''}
+ <h3 class="medium-heading">
+ Add attachments
+ </h3>
+ <div class="attachments">
+ ${this._attachments && this._attachments.map((attachment) => html`
+ <label>
+ <chops-checkbox
+ type="checkbox"
+ checked="true"
+ class="kept-attachment"
+ data-attachment-id=${attachment.attachmentId}
+ @checked-change=${this._keptAttachmentIdsChanged}
+ />
+ <a href=${attachment.viewUrl} target="_blank">
+ ${attachment.filename}
+ </a>
+ </label>
+ <br>
+ `)}
+ <mr-upload></mr-upload>
+ </div>
+ <mr-error
+ ?hidden=${!this._attachmentError}
+ >${this._attachmentError}</mr-error>
+ <div class="edit-controls">
+ <chops-checkbox
+ id="sendEmail"
+ ?checked=${this._sendEmail}
+ @checked-change=${this._setSendEmail}
+ >Send email</chops-checkbox>
+ <div>
+ <chops-button id="discard" @click=${this.cancel} class="de-emphasized">
+ Discard
+ </chops-button>
+ <chops-button id="save" @click=${this.save} class="emphasized">
+ Save changes
+ </chops-button>
+ </div>
+ </div>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ commentsByApproval: {type: Array},
+ issueRef: {type: Object},
+ fieldName: {type: String},
+ projectName: {type: String},
+ _attachmentError: {type: String},
+ _attachments: {type: Array},
+ _boldLines: {type: Array},
+ _editedDescription: {type: String},
+ _title: {type: String},
+ _keptAttachmentIds: {type: Object},
+ _sendEmail: {type: Boolean},
+ _prefs: {type: Object},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.commentsByApproval = issueV0.commentsByApprovalName(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.projectName = projectV0.viewedProjectName(state);
+ this._prefs = userV0.prefs(state);
+ }
+
+ /**
+ * Public function to open the issue description editing dialog.
+ * @param {Event} e
+ */
+ async open(e) {
+ await this.updateComplete;
+ this.shadowRoot.querySelector('chops-dialog').open();
+ this.fieldName = e.detail.fieldName;
+ this.reset();
+ }
+
+ /**
+ * Resets edit form.
+ */
+ async reset() {
+ await this.updateComplete;
+ this._attachmentError = '';
+ this._attachments = [];
+ this._boldLines = [];
+ this._keptAttachmentIds = new Set();
+
+ const uploader = this.shadowRoot.querySelector('mr-upload');
+ if (uploader) {
+ uploader.reset();
+ }
+
+ // Sets _editedDescription and _title.
+ this._initializeView(this.commentsByApproval, this.fieldName);
+
+ this.shadowRoot.querySelectorAll('.kept-attachment').forEach((checkbox) => {
+ checkbox.checked = true;
+ });
+ this.shadowRoot.querySelector('#sendEmail').checked = true;
+
+ this._sendEmail = true;
+ }
+
+ /**
+ * Cancels in-flight edit data.
+ */
+ async cancel() {
+ await this.updateComplete;
+ this.shadowRoot.querySelector('chops-dialog').close();
+ }
+
+ /**
+ * Sends the user's edit to Monorail's backend to be saved.
+ */
+ async save() {
+ const commentContent = this._markupNewContent();
+ const sendEmail = this._sendEmail;
+ const keptAttachments = Array.from(this._keptAttachmentIds);
+ const message = {
+ issueRef: this.issueRef,
+ isDescription: true,
+ commentContent,
+ keptAttachments,
+ sendEmail,
+ };
+
+ try {
+ const uploader = this.shadowRoot.querySelector('mr-upload');
+ const uploads = await uploader.loadFiles();
+ if (uploads && uploads.length) {
+ message.uploads = uploads;
+ }
+
+ if (!this.fieldName) {
+ store.dispatch(issueV0.update(message));
+ } else {
+ // This is editing an approval if there is no field name.
+ message.fieldRef = {
+ type: fieldTypes.APPROVAL_TYPE,
+ fieldName: this.fieldName,
+ };
+ store.dispatch(issueV0.updateApproval(message));
+ }
+ this.shadowRoot.querySelector('chops-dialog').close();
+ } catch (e) {
+ this._attachmentError = `Error while loading file for attachment: ${
+ e.message}`;
+ }
+ }
+
+ /**
+ * Getter for checking if the user has Markdown enabled.
+ * @return {boolean} Whether Markdown preview should be rendered or not.
+ */
+ get _renderMarkdown() {
+ const enabled = this._prefs.get('render_markdown');
+ return shouldRenderMarkdown({project: this.projectName, enabled});
+ }
+
+ /**
+ * Event handler for keeping <mr-edit-description>'s copy of
+ * _editedDescription in sync.
+ * @param {Event} e
+ */
+ _setEditedDescription(e) {
+ const target = e.target;
+ this._editedDescription = target.value;
+ }
+
+ /**
+ * Event handler for keeping attachment state in sync.
+ * @param {Event} e
+ */
+ _keptAttachmentIdsChanged(e) {
+ e.target.checked = e.detail.checked;
+ const attachmentId = Number.parseInt(e.target.dataset.attachmentId);
+ if (e.target.checked) {
+ this._keptAttachmentIds.add(attachmentId);
+ } else {
+ this._keptAttachmentIds.delete(attachmentId);
+ }
+ }
+
+ _initializeView(commentsByApproval, fieldName) {
+ this._title = fieldName ? `${fieldName} Survey` : 'Description';
+ const key = fieldName || '';
+ if (!commentsByApproval || !commentsByApproval.has(key)) return;
+ const comments = commentListToDescriptionList(commentsByApproval.get(key));
+
+ const comment = comments[comments.length - 1];
+
+ if (comment.attachments) {
+ this._keptAttachmentIds = new Set(comment.attachments.map(
+ (attachment) => Number.parseInt(attachment.attachmentId)));
+ this._attachments = comment.attachments;
+ }
+
+ this._processRawContent(comment.content);
+ }
+
+ _processRawContent(content) {
+ const chunks = content.trim().split(/(<b>[^<\n]+<\/b>)/m);
+ const boldLines = [];
+ let cleanContent = '';
+ chunks.forEach((chunk) => {
+ if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
+ const cleanChunk = chunk.slice(3, -4).trim();
+ cleanContent += cleanChunk;
+ // Don't add whitespace to boldLines.
+ if (/\S/.test(cleanChunk)) {
+ boldLines.push(cleanChunk);
+ }
+ } else {
+ cleanContent += chunk;
+ }
+ });
+
+ this._boldLines = boldLines;
+ this._editedDescription = cleanContent;
+ }
+
+ _markupNewContent() {
+ const lines = this._editedDescription.trim().split('\n');
+ const markedLines = lines.map((line) => {
+ let markedLine = line;
+ const matchingBoldLine = this._boldLines.find(
+ (boldLine) => (line.startsWith(boldLine)));
+ if (matchingBoldLine) {
+ markedLine =
+ `<b>${matchingBoldLine}</b>${line.slice(matchingBoldLine.length)}`;
+ }
+ return markedLine;
+ });
+ return markedLines.join('\n');
+ }
+
+ /**
+ * Event handler for keeping email state in sync.
+ * @param {Event} e
+ */
+ _setSendEmail(e) {
+ this._sendEmail = e.detail.checked;
+ }
+}
+
+customElements.define('mr-edit-description', MrEditDescription);
diff --git a/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js
new file mode 100644
index 0000000..e3fe9d2
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js
@@ -0,0 +1,136 @@
+// 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 {MrEditDescription} from './mr-edit-description.js';
+
+let element;
+
+describe('mr-edit-description', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-edit-description');
+
+ document.body.appendChild(element);
+ element.commentsByApproval = new Map([
+ ['', [
+ {
+ descriptionNum: 1,
+ content: 'first description',
+ },
+ {
+ content: 'first comment',
+ },
+ {
+ descriptionNum: 2,
+ content: '<b>last</b> description',
+ },
+ {
+ content: 'second comment',
+ },
+ {
+ content: 'third comment',
+ },
+ ]], ['foo', [
+ {
+ descriptionNum: 1,
+ content: 'first foo survey',
+ approvalRef: {
+ fieldName: 'foo',
+ },
+ },
+ {
+ descriptionNum: 2,
+ content: 'last foo survey',
+ approvalRef: {
+ fieldName: 'foo',
+ },
+ },
+ ]], ['bar', [
+ {
+ descriptionNum: 1,
+ content: 'bar survey',
+ approvalRef: {
+ fieldName: 'bar',
+ },
+ },
+ ]],
+ ]);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditDescription);
+ });
+
+ it('selects last issue description', async () => {
+ element.fieldName = '';
+ element.reset();
+
+ await element.updateComplete;
+
+ assert.equal(element._editedDescription, 'last description');
+ assert.equal(element._title, 'Description');
+ });
+
+ it('selects last survey', async () => {
+ element.fieldName = 'foo';
+ element.reset();
+
+ await element.updateComplete;
+
+ assert.equal(element._editedDescription, 'last foo survey');
+ assert.equal(element._title, 'foo Survey');
+ });
+
+ it('toggle sendEmail', async () => {
+ element.reset();
+ await element.updateComplete;
+
+ const sendEmail = element.shadowRoot.querySelector('#sendEmail');
+
+ await sendEmail.updateComplete;
+
+ sendEmail.click();
+ await element.updateComplete;
+ assert.isFalse(element._sendEmail);
+
+ sendEmail.click();
+ await element.updateComplete;
+ assert.isTrue(element._sendEmail);
+
+ sendEmail.click();
+ await element.updateComplete;
+ assert.isFalse(element._sendEmail);
+ });
+
+ it('renders valid markdown description with preview class', async () => {
+ element.projectName = 'monkeyrail';
+ element._prefs = new Map([['render_markdown', true]]);
+ element.reset();
+
+ element._editedDescription = '# h1';
+
+ await element.updateComplete;
+
+ const previewMarkdown = element.shadowRoot.querySelector('.markdown-preview');
+ assert.isNotNull(previewMarkdown);
+
+ const headerText = previewMarkdown.querySelector('h1').textContent;
+ assert.equal(headerText, 'h1');
+ });
+
+ it('does not show preview when markdown is disabled', async () => {
+ element.projectName = 'disabled_project';
+ element._prefs = new Map([['render_markdown', true]]);
+ element.reset();
+
+ await element.updateComplete;
+
+ const previewMarkdown = element.shadowRoot.querySelector('.markdown-preview');
+ assert.isNull(previewMarkdown);
+ });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js
new file mode 100644
index 0000000..e97f203
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js
@@ -0,0 +1,129 @@
+// 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, css} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import 'elements/framework/mr-autocomplete/mr-autocomplete.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 {prpcClient} from 'prpc-client-instance.js';
+
+export class MrMoveCopyIssue extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ .target-project-dialog {
+ display: block;
+ font-size: var(--chops-main-font-size);
+ }
+ .error {
+ max-width: 100%;
+ color: red;
+ margin-bottom: 1em;
+ }
+ .edit-actions {
+ width: 100%;
+ margin: 0.5em 0;
+ text-align: right;
+ }
+ input {
+ box-sizing: border-box;
+ width: 95%;
+ padding: 0.25em 4px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <chops-dialog closeOnOutsideClick>
+ <div class="target-project-dialog">
+ <h3 class="medium-heading">${this._action} issue</h3>
+ <div class="input-grid">
+ <label for="targetProjectInput">Target project:</label>
+ <div>
+ <input id="targetProjectInput" />
+ <mr-autocomplete
+ vocabularyName="project"
+ for="targetProjectInput"
+ ></mr-autocomplete>
+ </div>
+ </div>
+
+ ${this._targetProjectError ? html`
+ <div class="error">
+ ${this._targetProjectError}
+ </div>
+ ` : ''}
+
+ <div class="edit-actions">
+ <chops-button @click=${this.cancel} class="de-emphasized">
+ Cancel
+ </chops-button>
+ <chops-button @click=${this.save} class="emphasized">
+ ${this._action} issue
+ </chops-button>
+ </div>
+ </div>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issueRef: {type: Object},
+ _action: {type: String},
+ _targetProjectError: {type: String},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issueRef = issueV0.viewedIssueRef(state);
+ }
+
+ open(e) {
+ this.shadowRoot.querySelector('chops-dialog').open();
+ this._action = e.detail.action;
+ this.reset();
+ }
+
+ reset() {
+ this.shadowRoot.querySelector('#targetProjectInput').value = '';
+ this._targetProjectError = '';
+ }
+
+ cancel() {
+ this.shadowRoot.querySelector('chops-dialog').close();
+ }
+
+ save() {
+ const method = this._action + 'Issue';
+ prpcClient.call('monorail.Issues', method, {
+ issueRef: this.issueRef,
+ targetProjectName: this.shadowRoot.querySelector(
+ '#targetProjectInput').value,
+ }).then((response) => {
+ const projectName = response.newIssueRef.projectName;
+ const localId = response.newIssueRef.localId;
+ page(`/p/${projectName}/issues/detail?id=${localId}`);
+ this.cancel();
+ }, (error) => {
+ this._targetProjectError = error;
+ });
+ }
+}
+
+customElements.define('mr-move-copy-issue', MrMoveCopyIssue);
diff --git a/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js
new file mode 100644
index 0000000..5fdfb39
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js
@@ -0,0 +1,23 @@
+// 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 {MrMoveCopyIssue} from './mr-move-copy-issue.js';
+
+let element;
+
+describe('mr-move-copy-issue', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-move-copy-issue');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrMoveCopyIssue);
+ });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js
new file mode 100644
index 0000000..e859bef
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js
@@ -0,0 +1,316 @@
+// 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/chops/chops-dialog/chops-dialog.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {ISSUE_EDIT_PERMISSION} from 'shared/consts/permissions';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/**
+ * `<mr-related-issues>`
+ *
+ * Component for showing a mini list view of blocking issues to users.
+ */
+export class MrRelatedIssues extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: block;
+ font-size: var(--chops-main-font-size);
+ }
+ table {
+ word-wrap: break-word;
+ width: 100%;
+ }
+ tr {
+ font-weight: normal;
+ text-align: left;
+ margin: 0 auto;
+ padding: 2em 1em;
+ height: 20px;
+ }
+ td {
+ background: #f8f8f8;
+ padding: 4px;
+ padding-left: 8px;
+ text-overflow: ellipsis;
+ }
+ th {
+ text-decoration: none;
+ margin-right: 0;
+ padding-right: 0;
+ padding-left: 8px;
+ white-space: nowrap;
+ background: #e3e9ff;
+ text-align: left;
+ border-right: 1px solid #fff;
+ border-top: 1px solid #fff;
+ }
+ tr.dragged td {
+ background: #eee;
+ }
+ h3.medium-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ }
+ button {
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ margin: 0;
+ padding: 0;
+ }
+ i.material-icons {
+ font-size: var(--chops-icon-font-size);
+ color: var(--chops-primary-icon-color);
+ }
+ .draggable {
+ cursor: grab;
+ }
+ .error {
+ max-width: 100%;
+ color: red;
+ margin-bottom: 1em;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ const rerankEnabled = (this.issuePermissions ||
+ []).includes(ISSUE_EDIT_PERMISSION);
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <chops-dialog closeOnOutsideClick>
+ <h3 class="medium-heading">
+ <span>Blocked on issues</span>
+ <button aria-label="close" @click=${this.close}>
+ <i class="material-icons">close</i>
+ </button>
+ </h3>
+ ${this.error ? html`
+ <div class="error">${this.error}</div>
+ ` : ''}
+ <table><tbody>
+ <tr>
+ ${rerankEnabled ? html`<th></th>` : ''}
+ ${this.columns.map((column) => html`
+ <th>${column}</th>
+ `)}
+ </tr>
+
+ ${this._renderedRows.map((row, index) => html`
+ <tr
+ class=${index === this.srcIndex ? 'dragged' : ''}
+ draggable=${rerankEnabled && row.draggable}
+ data-index=${index}
+ @dragstart=${this._dragstart}
+ @dragend=${this._dragend}
+ @dragover=${this._dragover}
+ @drop=${this._dragdrop}
+ >
+ ${rerankEnabled ? html`
+ <td>
+ ${rerankEnabled && row.draggable ? html`
+ <i class="material-icons draggable">drag_indicator</i>
+ ` : ''}
+ </td>
+ ` : ''}
+
+ ${row.cells.map((cell) => html`
+ <td>
+ ${cell.type === 'issue' ? html`
+ <mr-issue-link
+ .projectName=${this.issueRef.projectName}
+ .issue=${cell.issue}
+ ></mr-issue-link>
+ ` : ''}
+ ${cell.type === 'text' ? cell.content : ''}
+ </td>
+ `)}
+ </tr>
+ `)}
+ </tbody></table>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ columns: {type: Array},
+ error: {type: String},
+ srcIndex: {type: Number},
+ issueRef: {type: Object},
+ issuePermissions: {type: Array},
+ sortedBlockedOn: {type: Array},
+ _renderedRows: {type: Array},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.issuePermissions = issueV0.permissions(state);
+ this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.columns = ['Issue', 'Summary'];
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('sortedBlockedOn')) {
+ this.reset();
+ }
+ super.update(changedProperties);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('issueRef')) {
+ this.close();
+ }
+ }
+
+ get _rows() {
+ const blockedOn = this.sortedBlockedOn;
+ if (!blockedOn) return [];
+ return blockedOn.map((issue) => {
+ const isClosed = issue.statusRef ? !issue.statusRef.meansOpen : false;
+ let summary = issue.summary;
+ if (issue.extIdentifier) {
+ // Some federated references will have summaries.
+ summary = issue.summary || '(not available)';
+ }
+ const row = {
+ // Disallow reranking FedRefs/DanglingIssueRelations.
+ draggable: !isClosed && !issue.extIdentifier,
+ cells: [
+ {
+ type: 'issue',
+ issue: issue,
+ isClosed: Boolean(isClosed),
+ },
+ {
+ type: 'text',
+ content: summary,
+ },
+ ],
+ };
+ return row;
+ });
+ }
+
+ async open() {
+ await this.updateComplete;
+ this.reset();
+ this.shadowRoot.querySelector('chops-dialog').open();
+ }
+
+ close() {
+ this.shadowRoot.querySelector('chops-dialog').close();
+ }
+
+ reset() {
+ this.error = null;
+ this.srcIndex = null;
+ this._renderedRows = this._rows.slice();
+ }
+
+ _dragstart(e) {
+ if (e.currentTarget.draggable) {
+ this.srcIndex = Number(e.currentTarget.dataset.index);
+ e.dataTransfer.setDragImage(new Image(), 0, 0);
+ }
+ }
+
+ _dragover(e) {
+ if (e.currentTarget.draggable && this.srcIndex !== null) {
+ e.preventDefault();
+ const targetIndex = Number(e.currentTarget.dataset.index);
+ this._reorderRows(this.srcIndex, targetIndex);
+ this.srcIndex = targetIndex;
+ }
+ }
+
+ _dragend(e) {
+ if (this.srcIndex !== null) {
+ this.reset();
+ }
+ }
+
+ _dragdrop(e) {
+ if (e.currentTarget.draggable && this.srcIndex !== null) {
+ const src = this._renderedRows[this.srcIndex];
+ if (this.srcIndex > 0) {
+ const target = this._renderedRows[this.srcIndex - 1];
+ const above = false;
+ this._reorderBlockedOn(src, target, above);
+ } else if (this.srcIndex === 0 &&
+ this._renderedRows[1] && this._renderedRows[1].draggable) {
+ const target = this._renderedRows[1];
+ const above = true;
+ this._reorderBlockedOn(src, target, above);
+ }
+ this.srcIndex = null;
+ }
+ }
+
+ _reorderBlockedOn(srcArg, targetArg, above) {
+ const src = srcArg.cells[0].issue;
+ const target = targetArg.cells[0].issue;
+
+ const reorderRequest = prpcClient.call(
+ 'monorail.Issues', 'RerankBlockedOnIssues', {
+ issueRef: this.issueRef,
+ movedRef: {
+ projectName: src.projectName,
+ localId: src.localId,
+ },
+ targetRef: {
+ projectName: target.projectName,
+ localId: target.localId,
+ },
+ splitAbove: above,
+ });
+
+ reorderRequest.then((response) => {
+ store.dispatch(issueV0.fetch(this.issueRef));
+ }, (error) => {
+ this.reset();
+ this.error = error.description;
+ });
+ }
+
+ _reorderRows(srcIndex, toIndex) {
+ if (srcIndex <= toIndex) {
+ this._renderedRows = this._renderedRows.slice(0, srcIndex).concat(
+ this._renderedRows.slice(srcIndex + 1, toIndex + 1),
+ [this._renderedRows[srcIndex]],
+ this._renderedRows.slice(toIndex + 1));
+ } else {
+ this._renderedRows = this._renderedRows.slice(0, toIndex).concat(
+ [this._renderedRows[srcIndex]],
+ this._renderedRows.slice(toIndex, srcIndex),
+ this._renderedRows.slice(srcIndex + 1));
+ }
+ }
+}
+
+customElements.define('mr-related-issues', MrRelatedIssues);
diff --git a/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js
new file mode 100644
index 0000000..69ce7ee
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js
@@ -0,0 +1,191 @@
+// 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 {MrRelatedIssues} from './mr-related-issues.js';
+
+
+let element;
+
+describe('mr-related-issues', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-related-issues');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrRelatedIssues);
+ });
+
+ it('dialog closes when issueRef changes', async () => {
+ element.issueRef = {projectName: 'chromium', localId: 22};
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector('chops-dialog');
+
+ element.open();
+ await element.updateComplete;
+
+ assert.isTrue(dialog.opened);
+
+ element.issueRef = {projectName: 'chromium', localId: 23};
+ await element.updateComplete;
+
+ assert.isFalse(dialog.opened);
+ });
+
+ it('computes blocked on table rows', () => {
+ element.projectName = 'proj';
+ element.sortedBlockedOn = [
+ {projectName: 'proj', localId: 1, statusRef: {meansOpen: true},
+ summary: 'Issue 1'},
+ {projectName: 'proj', localId: 2, statusRef: {meansOpen: true},
+ summary: 'Issue 2'},
+ {projectName: 'proj', localId: 3,
+ summary: 'Issue 3'},
+ {projectName: 'proj2', localId: 4,
+ summary: 'Issue 4 on another project'},
+ {extIdentifier: 'b/123456', statusRef: {meansOpen: true}},
+ {extIdentifier: 'b/987654', statusRef: {meansOpen: false},
+ summary: 'FedRef with a summary'},
+ {projectName: 'proj', localId: 5, statusRef: {meansOpen: false},
+ summary: 'Issue 5'},
+ {projectName: 'proj2', localId: 6, statusRef: {meansOpen: false},
+ summary: 'Issue 6 on another project'},
+ ];
+ assert.deepEqual(element._rows, [
+ {
+ draggable: true,
+ cells: [
+ {
+ type: 'issue',
+ issue: {projectName: 'proj', localId: 1, statusRef: {meansOpen: true},
+ summary: 'Issue 1'},
+ isClosed: false,
+ },
+ {
+ type: 'text',
+ content: 'Issue 1',
+ },
+ ],
+ },
+ {
+ draggable: true,
+ cells: [
+ {
+ type: 'issue',
+ issue: {projectName: 'proj', localId: 2, statusRef: {meansOpen: true},
+ summary: 'Issue 2'},
+ isClosed: false,
+ },
+ {
+ type: 'text',
+ content: 'Issue 2',
+ },
+ ],
+ },
+ {
+ draggable: true,
+ cells: [
+ {
+ type: 'issue',
+ issue: {projectName: 'proj', localId: 3,
+ summary: 'Issue 3'},
+ isClosed: false,
+ },
+ {
+ type: 'text',
+ content: 'Issue 3',
+ },
+ ],
+ },
+ {
+ draggable: true,
+ cells: [
+ {
+ type: 'issue',
+ issue: {projectName: 'proj2', localId: 4,
+ summary: 'Issue 4 on another project'},
+ isClosed: false,
+ },
+ {
+ type: 'text',
+ content: 'Issue 4 on another project',
+ },
+ ],
+ },
+ {
+ draggable: false,
+ cells: [
+ {
+ type: 'issue',
+ issue: {
+ extIdentifier: 'b/123456',
+ statusRef: {meansOpen: true},
+ },
+ isClosed: false,
+ },
+ {
+ type: 'text',
+ content: '(not available)',
+ },
+ ],
+ },
+ {
+ draggable: false,
+ cells: [
+ {
+ type: 'issue',
+ issue: {
+ extIdentifier: 'b/987654',
+ statusRef: {meansOpen: false},
+ summary: 'FedRef with a summary',
+ },
+ isClosed: true,
+ },
+ {
+ type: 'text',
+ content: 'FedRef with a summary',
+ },
+ ],
+ },
+ {
+ draggable: false,
+ cells: [
+ {
+ type: 'issue',
+ issue: {projectName: 'proj', localId: 5,
+ statusRef: {meansOpen: false},
+ summary: 'Issue 5'},
+ isClosed: true,
+ },
+ {
+ type: 'text',
+ content: 'Issue 5',
+ },
+ ],
+ },
+ {
+ draggable: false,
+ cells: [
+ {
+ type: 'issue',
+ issue: {projectName: 'proj2', localId: 6,
+ statusRef: {meansOpen: false},
+ summary: 'Issue 6 on another project'},
+ isClosed: true,
+ },
+ {
+ type: 'text',
+ content: 'Issue 6 on another project',
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js
new file mode 100644
index 0000000..18bd963
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js
@@ -0,0 +1,288 @@
+// 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} from 'lit-element';
+
+import deepEqual from 'deep-equal';
+import {fieldTypes, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {arrayDifference, equalsIgnoreCase} from 'shared/helpers.js';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+
+import './mr-multi-checkbox.js';
+import 'react/mr-react-autocomplete.tsx';
+
+const AUTOCOMPLETE_INPUT = 'AUTOCOMPLETE_INPUT';
+const CHECKBOX_INPUT = 'CHECKBOX_INPUT';
+const SELECT_INPUT = 'SELECT_INPUT';
+
+/**
+ * `<mr-edit-field>`
+ *
+ * A single edit input for a fieldDef + the values of the field.
+ *
+ */
+export class MrEditField extends LitElement {
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <style>
+ mr-edit-field {
+ display: block;
+ }
+ mr-edit-field[hidden] {
+ display: none;
+ }
+ mr-edit-field input,
+ mr-edit-field select {
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ }
+ </style>
+ ${this._renderInput()}
+ `;
+ }
+
+ /**
+ * Renders a single input field.
+ * @return {TemplateResult}
+ */
+ _renderInput() {
+ switch (this._widgetType) {
+ case CHECKBOX_INPUT:
+ return html`
+ <mr-multi-checkbox
+ .options=${this.options}
+ .values=${[...this.values]}
+ @change=${this._changeHandler}
+ ></mr-multi-checkbox>
+ `;
+ case SELECT_INPUT:
+ return html`
+ <select
+ id="${this.label}"
+ class="editSelect"
+ aria-label=${this.name}
+ @change=${this._changeHandler}
+ >
+ <option value="">${EMPTY_FIELD_VALUE}</option>
+ ${this.options.map((option) => html`
+ <option
+ value=${option.optionName}
+ .selected=${this.value === option.optionName}
+ >
+ ${option.optionName}
+ ${option.docstring ? ' = ' + option.docstring : ''}
+ </option>
+ `)}
+ </select>
+ `;
+ case AUTOCOMPLETE_INPUT:
+ return html`
+ <mr-react-autocomplete
+ .label=${this.label}
+ .vocabularyName=${this.acType || ''}
+ .inputType=${this._html5InputType}
+ .fixedValues=${this.derivedValues}
+ .value=${this.multi ? this.values : this.value}
+ .multiple=${this.multi}
+ .onChange=${this._changeHandlerReact.bind(this)}
+ ></mr-react-autocomplete>
+ `;
+ default:
+ return '';
+ }
+ }
+
+
+ /** @override */
+ static get properties() {
+ return {
+ // TODO(zhangtiff): Redesign this a bit so we don't need two separate
+ // ways of specifying "type" for a field. Right now, "type" is mapped to
+ // the Monorail custom field types whereas "acType" includes additional
+ // data types such as components, and labels.
+ // String specifying what kind of autocomplete to add to this field.
+ acType: {type: String},
+ // "type" is based on the various custom field types available in
+ // Monorail.
+ type: {type: String},
+ label: {type: String},
+ multi: {type: Boolean},
+ name: {type: String},
+ // Only used for basic, non-repeated fields.
+ placeholder: {type: String},
+ initialValues: {
+ type: Array,
+ hasChanged(newVal, oldVal) {
+ // Prevent extra recomputations of the same initial value causing
+ // values to be reset.
+ return !deepEqual(newVal, oldVal);
+ },
+ },
+ // The current user-inputted values for a field.
+ values: {type: Array},
+ derivedValues: {type: Array},
+ // For enum fields, the possible options that you have. Each entry is a
+ // label type with an additional optionName field added.
+ options: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.initialValues = [];
+ this.values = [];
+ this.derivedValues = [];
+ this.options = [];
+ this.multi = false;
+
+ this.actType = '';
+ this.placeholder = '';
+ this.type = '';
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('initialValues')) {
+ // Assume we always want to reset the user's input when initial
+ // values change.
+ this.reset();
+ }
+ super.update(changedProperties);
+ }
+
+ /**
+ * @return {string}
+ */
+ get value() {
+ return _getSingleValue(this.values);
+ }
+
+ /**
+ * @return {string}
+ */
+ get _widgetType() {
+ const type = this.type;
+ const multi = this.multi;
+ if (type === fieldTypes.ENUM_TYPE) {
+ if (multi) {
+ return CHECKBOX_INPUT;
+ }
+ return SELECT_INPUT;
+ } else {
+ return AUTOCOMPLETE_INPUT;
+ }
+ }
+
+ /**
+ * @return {string} HTML type for the input.
+ */
+ get _html5InputType() {
+ const type = this.type;
+ if (type === fieldTypes.INT_TYPE) {
+ return 'number';
+ } else if (type === fieldTypes.DATE_TYPE) {
+ return 'date';
+ }
+ return 'text';
+ }
+
+ /**
+ * Reset form values to initial state.
+ */
+ reset() {
+ this.values = _wrapInArray(this.initialValues);
+ }
+
+ /**
+ * Return the values that the user added to this input.
+ * @return {Array<string>}åß
+ */
+ getValuesAdded() {
+ if (!this.values || !this.values.length) return [];
+ return arrayDifference(
+ this.values, this.initialValues, equalsIgnoreCase);
+ }
+
+ /**
+ * Return the values that the userremoved from this input.
+ * @return {Array<string>}
+ */
+ getValuesRemoved() {
+ if (!this.multi && (!this.values || this.values.length > 0)) return [];
+ return arrayDifference(
+ this.initialValues, this.values, equalsIgnoreCase);
+ }
+
+ /**
+ * Syncs form values and fires a change event as the user edits the form.
+ * @param {Event} e
+ * @fires Event#change
+ * @private
+ */
+ _changeHandler(e) {
+ if (e instanceof KeyboardEvent) {
+ if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+ }
+ const input = e.target;
+
+ if (input.getValues) {
+ // <mr-multi-checkbox> support.
+ this.values = input.getValues();
+ } else {
+ // Is a native input element.
+ const value = input.value.trim();
+ this.values = _wrapInArray(value);
+ }
+
+ this.dispatchEvent(new Event('change'));
+ }
+
+ /**
+ * Syncs form values and fires a change event as the user edits the form.
+ * @param {React.SyntheticEvent} _e
+ * @param {string|Array<string>|null} value React autcoomplete form value.
+ * @fires Event#change
+ * @private
+ */
+ _changeHandlerReact(_e, value) {
+ this.values = _wrapInArray(value);
+
+ this.dispatchEvent(new Event('change'));
+ }
+}
+
+/**
+ * Returns the string value for a single field.
+ * @param {Array<string>} arr
+ * @return {string}
+ */
+function _getSingleValue(arr) {
+ return (arr && arr.length) ? arr[0] : '';
+}
+
+/**
+ * Returns the string value for a single field.
+ * @param {Array<string>|string} v
+ * @return {string}
+ */
+function _wrapInArray(v) {
+ if (!v) return [];
+
+ let values = v;
+ if (!Array.isArray(v)) {
+ values = !!v ? [v] : [];
+ }
+ return [...values];
+}
+
+customElements.define('mr-edit-field', MrEditField);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js
new file mode 100644
index 0000000..a718203
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js
@@ -0,0 +1,215 @@
+// 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 userEvent from '@testing-library/user-event';
+
+import {MrEditField} from './mr-edit-field.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+
+import {enterInput} from 'shared/test/helpers.js';
+
+
+let element;
+let input;
+
+xdescribe('mr-edit-field', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-edit-field');
+ document.body.appendChild(element);
+
+ element.label = 'testInput';
+ await element.updateComplete;
+
+ input = element.querySelector('#testInput');
+ });
+
+ afterEach(async () => {
+ userEvent.clear(input);
+
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditField);
+ });
+
+ it('reset input value', async () => {
+ element.initialValues = [];
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.equal(element.value, 'jackalope');
+
+ element.reset();
+ await element.updateComplete;
+
+ assert.equal(element.value, '');
+ });
+
+ it('input updates when initialValues change', async () => {
+ element.initialValues = ['hello'];
+
+ await element.updateComplete;
+
+ assert.equal(element.value, 'hello');
+ });
+
+ it('initial value does not change after value set', async () => {
+ element.initialValues = ['hello'];
+ element.label = 'testInput';
+ await element.updateComplete;
+
+ input = element.querySelector('#testInput');
+
+ enterInput(input, 'world');
+ await element.updateComplete;
+
+ assert.deepEqual(element.initialValues, ['hello']);
+ assert.equal(element.value, 'world');
+ });
+
+ it('value updates when input is updated', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'world');
+ await element.updateComplete;
+
+ assert.equal(element.value, 'world');
+ });
+
+ it('initial value does not change after user input', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.deepEqual(element.initialValues, ['hello']);
+ assert.equal(element.value, 'jackalope');
+ });
+
+ it('get value after user input', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.equal(element.value, 'jackalope');
+ });
+
+ it('input value was added', async () => {
+ // Simulate user input.
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.deepEqual(element.getValuesAdded(), ['jackalope']);
+ assert.deepEqual(element.getValuesRemoved(), []);
+ });
+
+ it('input value was removed', async () => {
+ await element.updateComplete;
+
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, '');
+ await element.updateComplete;
+
+ assert.deepEqual(element.getValuesAdded(), []);
+ assert.deepEqual(element.getValuesRemoved(), ['hello']);
+ });
+
+ it('input value was changed', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'world');
+ await element.updateComplete;
+
+ assert.deepEqual(element.getValuesAdded(), ['world']);
+ });
+
+ it('edit select updates value when initialValues change', async () => {
+ element.multi = false;
+ element.type = fieldTypes.ENUM_TYPE;
+
+ element.options = [
+ {optionName: 'hello'},
+ {optionName: 'jackalope'},
+ {optionName: 'text'},
+ ];
+
+ element.initialValues = ['hello'];
+
+ await element.updateComplete;
+
+ assert.equal(element.value, 'hello');
+
+ const select = element.querySelector('select');
+ userEvent.selectOptions(select, 'jackalope');
+
+ // User input should not be overridden by the initialValue variable.
+ assert.equal(element.value, 'jackalope');
+ // Initial values should not change based on user input.
+ assert.deepEqual(element.initialValues, ['hello']);
+
+ element.initialValues = ['text'];
+ await element.updateComplete;
+
+ assert.equal(element.value, 'text');
+
+ element.initialValues = [];
+ await element.updateComplete;
+
+ assert.deepEqual(element.value, '');
+ });
+
+ it('multi enum updates value on reset', async () => {
+ element.multi = true;
+ element.type = fieldTypes.ENUM_TYPE;
+ element.options = [
+ {optionName: 'hello'},
+ {optionName: 'world'},
+ {optionName: 'fake'},
+ ];
+
+ await element.updateComplete;
+
+ element.initialValues = ['hello'];
+ element.reset();
+ await element.updateComplete;
+
+ assert.deepEqual(element.values, ['hello']);
+
+ const checkboxes = element.querySelector('mr-multi-checkbox');
+
+ // User checks all boxes.
+ checkboxes._inputRefs.forEach(
+ (checkbox) => {
+ checkbox.checked = true;
+ },
+ );
+ checkboxes._changeHandler();
+
+ await element.updateComplete;
+
+ // User input should not be overridden by the initialValues variable.
+ assert.deepEqual(element.values, ['hello', 'world', 'fake']);
+ // Initial values should not change based on user input.
+ assert.deepEqual(element.initialValues, ['hello']);
+
+ element.initialValues = ['hello', 'world'];
+ element.reset();
+ await element.updateComplete;
+
+ assert.deepEqual(element.values, ['hello', 'world']);
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js
new file mode 100644
index 0000000..5303c57
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js
@@ -0,0 +1,183 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles';
+import './mr-edit-field.js';
+
+/**
+ * `<mr-edit-status>`
+ *
+ * Editing form for either an approval or the overall issue.
+ *
+ */
+export class MrEditStatus extends LitElement {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ width: 100%;
+ }
+ select {
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ }
+ .grid-input {
+ margin-top: 8px;
+ display: grid;
+ grid-gap: var(--mr-input-grid-gap);
+ grid-template-columns: auto 1fr;
+ }
+ .grid-input[hidden] {
+ display: none;
+ }
+ label {
+ font-weight: bold;
+ word-wrap: break-word;
+ text-align: left;
+ }
+ #mergedIntoInput {
+ width: 160px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <select
+ @change=${this._selectChangeHandler}
+ aria-label="Status"
+ id="statusInput"
+ >
+ ${this._statusesGrouped.map((group) => html`
+ <optgroup label=${group.name} ?hidden=${!group.name}>
+ ${group.statuses.map((item) => html`
+ <option
+ value=${item.status}
+ .selected=${this.status === item.status}
+ >
+ ${item.status}
+ ${item.docstring ? `= ${item.docstring}` : ''}
+ </option>
+ `)}
+ </optgroup>
+
+ ${!group.name ? html`
+ ${group.statuses.map((item) => html`
+ <option
+ value=${item.status}
+ .selected=${this.status === item.status}
+ >
+ ${item.status}
+ ${item.docstring ? `= ${item.docstring}` : ''}
+ </option>
+ `)}
+ ` : ''}
+ `)}
+ </select>
+
+ <div class="grid-input" ?hidden=${!this._showMergedInto}>
+ <label for="mergedIntoInput" id="mergedIntoLabel">Merged into:</label>
+ <input
+ id="mergedIntoInput"
+ value=${this.mergedInto || ''}
+ @change=${this._changeHandler}
+ ></input>
+ </div>`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ initialStatus: {type: String},
+ status: {type: String},
+ statuses: {type: Array},
+ isApproval: {type: Boolean},
+ mergedInto: {type: String},
+ };
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('initialStatus')) {
+ this.status = this.initialStatus;
+ }
+ super.update(changedProperties);
+ }
+
+ get _showMergedInto() {
+ const status = this.status || this.initialStatus;
+ return (status === 'Duplicate');
+ }
+
+ get _statusesGrouped() {
+ const statuses = this.statuses;
+ const isApproval = this.isApproval;
+ if (!statuses) return [];
+ if (isApproval) {
+ return [{statuses: statuses}];
+ }
+ return [
+ {
+ name: 'Open',
+ statuses: statuses.filter((s) => s.meansOpen),
+ },
+ {
+ name: 'Closed',
+ statuses: statuses.filter((s) => !s.meansOpen),
+ },
+ ];
+ }
+
+ async reset() {
+ await this.updateComplete;
+ const mergedIntoInput = this.shadowRoot.querySelector('#mergedIntoInput');
+ if (mergedIntoInput) {
+ mergedIntoInput.value = this.mergedInto || '';
+ }
+ this.status = this.initialStatus;
+ }
+
+ get delta() {
+ const result = {};
+
+ if (this.status !== this.initialStatus) {
+ result['status'] = this.status;
+ }
+
+ if (this._showMergedInto) {
+ const newMergedInto = this.shadowRoot.querySelector(
+ '#mergedIntoInput').value;
+ if (newMergedInto !== this.mergedInto) {
+ result['mergedInto'] = newMergedInto;
+ }
+ } else if (this.initialStatus === 'Duplicate') {
+ result['mergedInto'] = '';
+ }
+
+ return result;
+ }
+
+ _selectChangeHandler(e) {
+ const statusInput = e.target;
+ this.status = statusInput.value;
+ this._changeHandler(e);
+ }
+
+ /**
+ * @param {Event} e
+ * @fires CustomEvent#change
+ * @private
+ */
+ _changeHandler(e) {
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+}
+
+customElements.define('mr-edit-status', MrEditStatus);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js
new file mode 100644
index 0000000..ffa25e5
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.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 {assert} from 'chai';
+import {MrEditStatus} from './mr-edit-status.js';
+
+
+let element;
+
+describe('mr-edit-status', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-edit-status');
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Old'},
+ {'status': 'Duplicate'},
+ ];
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditStatus);
+ });
+
+ it('delta empty when no changes', () => {
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('change status', async () => {
+ element.initialStatus = 'New';
+
+ await element.updateComplete;
+
+ const statusInput = element.shadowRoot.querySelector('select');
+ statusInput.value = 'Old';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {status: 'Old'});
+ });
+
+ it('mark as duplicate', async () => {
+ element.initialStatus = 'New';
+
+ await element.updateComplete;
+
+ const statusInput = element.shadowRoot.querySelector('select');
+ statusInput.value = 'Duplicate';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('#mergedIntoInput').value = 'proj:123';
+ assert.deepEqual(element.delta, {
+ status: 'Duplicate',
+ mergedInto: 'proj:123',
+ });
+ });
+
+ it('remove mark as duplicate', async () => {
+ element.initialStatus = 'Duplicate';
+ element.mergedInto = 'chromium:1234';
+
+ await element.updateComplete;
+
+ const statusInput = element.shadowRoot.querySelector('select');
+ statusInput.value = 'New';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ status: 'New',
+ mergedInto: '',
+ });
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js
new file mode 100644
index 0000000..881cced
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js
@@ -0,0 +1,96 @@
+// 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';
+
+/**
+ * `<mr-multi-checkbox>`
+ *
+ * A web component for managing values in a set of checkboxes.
+ *
+ */
+export class MrMultiCheckbox extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ input[type="checkbox"] {
+ width: auto;
+ height: auto;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ ${this.options.map((option) => html`
+ <label title=${option.docstring}>
+ <input
+ type="checkbox"
+ name=${this.name}
+ value=${option.optionName}
+ ?checked=${this.values.includes(option.optionName)}
+ @change=${this._changeHandler}
+ />
+ ${option.optionName}
+ </label>
+ `)}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ values: {type: Array},
+ options: {type: Array},
+ _inputRefs: {type: Object},
+ };
+ }
+
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('options')) {
+ this._inputRefs = this.shadowRoot.querySelectorAll('input');
+ }
+
+ if (changedProperties.has('values')) {
+ this.reset();
+ }
+ }
+
+ reset() {
+ this.setValues(this.values);
+ }
+
+ getValues() {
+ if (!this._inputRefs) return;
+ const valueList = [];
+ this._inputRefs.forEach((c) => {
+ if (c.checked) {
+ valueList.push(c.value.trim());
+ }
+ });
+ return valueList;
+ }
+
+ setValues(values) {
+ if (!this._inputRefs) return;
+ this._inputRefs.forEach(
+ (checkbox) => {
+ checkbox.checked = values.includes(checkbox.value);
+ },
+ );
+ }
+
+ /**
+ * @fires CustomEvent#change
+ * @private
+ */
+ _changeHandler() {
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+}
+
+customElements.define('mr-multi-checkbox', MrMultiCheckbox);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js
new file mode 100644
index 0000000..33cce9e
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js
@@ -0,0 +1,23 @@
+// 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 {MrMultiCheckbox} from './mr-multi-checkbox.js';
+
+let element;
+
+describe('mr-multi-checkbox', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-multi-checkbox');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrMultiCheckbox);
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
new file mode 100644
index 0000000..69ef43f
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
@@ -0,0 +1,360 @@
+// 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} from 'lit-element';
+import debounce from 'debounce';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as ui from 'reducers/ui.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+import './mr-edit-metadata.js';
+import 'shared/typedef.js';
+
+import ClientLogger from 'monitoring/client-logger.js';
+
+const DEBOUNCED_PRESUBMIT_TIME_OUT = 400;
+
+/**
+ * `<mr-edit-issue>`
+ *
+ * Edit form for a single issue. Wraps <mr-edit-metadata>.
+ *
+ */
+export class MrEditIssue extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ const issue = this.issue || {};
+ let blockedOnRefs = issue.blockedOnIssueRefs || [];
+ if (issue.danglingBlockedOnRefs && issue.danglingBlockedOnRefs.length) {
+ blockedOnRefs = blockedOnRefs.concat(issue.danglingBlockedOnRefs);
+ }
+
+ let blockingRefs = issue.blockingIssueRefs || [];
+ if (issue.danglingBlockingRefs && issue.danglingBlockingRefs.length) {
+ blockingRefs = blockingRefs.concat(issue.danglingBlockingRefs);
+ }
+
+ return html`
+ <h2 id="makechanges" class="medium-heading">
+ <a href="#makechanges">Add a comment and make changes</a>
+ </h2>
+ <mr-edit-metadata
+ formName="Issue Edit"
+ .ownerName=${this._ownerDisplayName(this.issue.ownerRef)}
+ .cc=${issue.ccRefs}
+ .status=${issue.statusRef && issue.statusRef.status}
+ .statuses=${this._availableStatuses(this.projectConfig.statusDefs, this.issue.statusRef)}
+ .summary=${issue.summary}
+ .components=${issue.componentRefs}
+ .fieldDefs=${this._fieldDefs}
+ .fieldValues=${issue.fieldValues}
+ .blockedOn=${blockedOnRefs}
+ .blocking=${blockingRefs}
+ .mergedInto=${issue.mergedIntoIssueRef}
+ .labelNames=${this._labelNames}
+ .derivedLabels=${this._derivedLabels}
+ .error=${this.updateError}
+ ?saving=${this.updatingIssue}
+ @save=${this.save}
+ @discard=${this.reset}
+ @change=${this._onChange}
+ ></mr-edit-metadata>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * All comments, including descriptions.
+ */
+ comments: {
+ type: Array,
+ },
+ /**
+ * The issue being updated.
+ */
+ issue: {
+ type: Object,
+ },
+ /**
+ * The issueRef for the currently viewed issue.
+ */
+ issueRef: {
+ type: Object,
+ },
+ /**
+ * The config of the currently viewed project.
+ */
+ projectConfig: {
+ type: Object,
+ },
+ /**
+ * Whether the issue is currently being updated.
+ */
+ updatingIssue: {
+ type: Boolean,
+ },
+ /**
+ * An error response, if one exists.
+ */
+ updateError: {
+ type: String,
+ },
+ /**
+ * Hash from the URL, used to support the 'r' hot key for making changes.
+ */
+ focusId: {
+ type: String,
+ },
+ _fieldDefs: {
+ type: Array,
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.clientLogger = new ClientLogger('issues');
+ this.updateError = '';
+
+ this.presubmitDebounceTimeOut = DEBOUNCED_PRESUBMIT_TIME_OUT;
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ // Prevent debounced logic from running after the component has been
+ // removed from the UI.
+ if (this._debouncedPresubmit) {
+ this._debouncedPresubmit.clear();
+ }
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.comments = issueV0.comments(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.updatingIssue = issueV0.requests(state).update.requesting;
+
+ const error = issueV0.requests(state).update.error;
+ this.updateError = error && (error.description || error.message);
+ this.focusId = ui.focusId(state);
+ this._fieldDefs = issueV0.fieldDefs(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (this.focusId && changedProperties.has('focusId')) {
+ // TODO(zhangtiff): Generalize logic to focus elements based on ID
+ // to a reuseable class mixin.
+ if (this.focusId.toLowerCase() === 'makechanges') {
+ this.focus();
+ }
+ }
+
+ if (changedProperties.has('updatingIssue')) {
+ const isUpdating = this.updatingIssue;
+ const wasUpdating = changedProperties.get('updatingIssue');
+
+ // When an issue finishes updating, we want to show a snackbar, record
+ // issue update time metrics, and reset the edit form.
+ if (!isUpdating && wasUpdating) {
+ if (!this.updateError) {
+ this._showCommentAddedSnackbar();
+ // Reset the edit form when a user's action finishes.
+ this.reset();
+ }
+
+ // Record metrics on when the issue editing event finished.
+ if (this.clientLogger.started('issue-update')) {
+ this.clientLogger.logEnd('issue-update', 'computer-time', 120 * 1000);
+ }
+ }
+ }
+ }
+
+ // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
+ /**
+ * Snows a snackbar telling the user they added a comment to the issue.
+ */
+ _showCommentAddedSnackbar() {
+ store.dispatch(ui.showSnackbar(ui.snackbarNames.ISSUE_COMMENT_ADDED,
+ 'Your comment was added.'));
+ }
+
+ /**
+ * Resets all form fields to their initial values.
+ */
+ reset() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+ form.reset();
+ }
+
+ /**
+ * Dispatches an action to save issue changes on the server.
+ */
+ async save() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+
+ const delta = form.delta;
+ if (!allowRemovedRestrictions(delta.labelRefsRemove)) {
+ return;
+ }
+
+ const message = {
+ issueRef: this.issueRef,
+ delta: delta,
+ commentContent: form.getCommentContent(),
+ sendEmail: form.sendEmail,
+ };
+
+ // Add files to message.
+ const uploads = await form.getAttachments();
+
+ if (uploads && uploads.length) {
+ message.uploads = uploads;
+ }
+
+ if (message.commentContent || message.delta || message.uploads) {
+ this.clientLogger.logStart('issue-update', 'computer-time');
+
+ store.dispatch(issueV0.update(message));
+ }
+ }
+
+ /**
+ * Focuses the edit form in response to the 'r' hotkey.
+ */
+ focus() {
+ const editHeader = this.querySelector('#makechanges');
+ editHeader.scrollIntoView();
+
+ const editForm = this.querySelector('mr-edit-metadata');
+ editForm.focus();
+ }
+
+ /**
+ * Turns all LabelRef Objects attached to an issue into an Array of strings
+ * containing only the names of those labels that aren't derived.
+ * @return {Array<string>} Array of label names.
+ */
+ get _labelNames() {
+ if (!this.issue || !this.issue.labelRefs) return [];
+ const labels = this.issue.labelRefs;
+ return labels.filter((l) => !l.isDerived).map((l) => l.label);
+ }
+
+ /**
+ * Finds only the derived labels attached to an issue and returns only
+ * their names.
+ * @return {Array<string>} Array of label names.
+ */
+ get _derivedLabels() {
+ if (!this.issue || !this.issue.labelRefs) return [];
+ const labels = this.issue.labelRefs;
+ return labels.filter((l) => l.isDerived).map((l) => l.label);
+ }
+
+ /**
+ * Gets the displayName of the owner. Only uses the displayName if a
+ * userId also exists in the ref.
+ * @param {UserRef} ownerRef The owner of the issue.
+ * @return {string} The name of the owner for the edited issue.
+ */
+ _ownerDisplayName(ownerRef) {
+ return (ownerRef && ownerRef.userId) ? ownerRef.displayName : '';
+ }
+
+ /**
+ * Dispatches an action against the server to run "issue presubmit", a feature
+ * that warns the user about issue changes that violate configured rules.
+ * @param {Object=} issueDelta Changes currently present in the edit form.
+ * @param {string} commentContent Text the user is inputting for a comment.
+ */
+ _presubmitIssue(issueDelta = {}, commentContent) {
+ // Don't run this functionality if the element has disconnected. Important
+ // for preventing debounced code from running after an element no longer
+ // exists.
+ if (!this.isConnected) return;
+
+ if (Object.keys(issueDelta).length || commentContent) {
+ // TODO(crbug.com/monorail/8638): Make filter rules actually process
+ // the text for comments on the backend.
+ store.dispatch(issueV0.presubmit(this.issueRef, issueDelta));
+ }
+ }
+
+ /**
+ * Form change handler that runs presubmit on the form.
+ * @param {CustomEvent} evt
+ */
+ _onChange(evt) {
+ const {delta, commentContent} = evt.detail || {};
+
+ if (!this._debouncedPresubmit) {
+ this._debouncedPresubmit = debounce(
+ (delta, commentContent) => this._presubmitIssue(delta, commentContent),
+ this.presubmitDebounceTimeOut);
+ }
+ this._debouncedPresubmit(delta, commentContent);
+ }
+
+ /**
+ * Creates the list of statuses that the user sees in the status dropdown.
+ * @param {Array<StatusDef>} statusDefsArg The project configured StatusDefs.
+ * @param {StatusRef} currentStatusRef The status that the issue currently
+ * uses. Note that Monorail supports free text statuses that do not exist in
+ * a project config. Because of this, currentStatusRef may not exist in
+ * statusDefsArg.
+ * @return {Array<StatusRef|StatusDef>} Array of statuses a user can edit this
+ * issue to have.
+ */
+ _availableStatuses(statusDefsArg, currentStatusRef) {
+ let statusDefs = statusDefsArg || [];
+ statusDefs = statusDefs.filter((status) => !status.deprecated);
+ if (!currentStatusRef || statusDefs.find(
+ (status) => status.status === currentStatusRef.status)) {
+ return statusDefs;
+ }
+ return [currentStatusRef, ...statusDefs];
+ }
+}
+
+/**
+ * Asks the user for confirmation when they try to remove retriction labels.
+ * eg. Restrict-View-Google.
+ * @param {Array<LabelRef>} labelRefsRemoved The labels a user is removing
+ * from this issue.
+ * @return {boolean} Whether removing these labels is okay. ie: true if there
+ * are either no restrictions being removed or if the user approved the
+ * removal of the restrictions.
+ */
+export function allowRemovedRestrictions(labelRefsRemoved) {
+ if (!labelRefsRemoved) return true;
+ const removedRestrictions = labelRefsRemoved
+ .map(({label}) => label)
+ .filter((label) => label.toLowerCase().startsWith('restrict-'));
+ const removeRestrictionsMessage =
+ 'You are removing these restrictions:\n' +
+ arrayToEnglish(removedRestrictions) + '\n' +
+ 'This might allow more people to access this issue. Are you sure?';
+ return !removedRestrictions.length || confirm(removeRestrictionsMessage);
+}
+
+customElements.define('mr-edit-issue', MrEditIssue);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js
new file mode 100644
index 0000000..a3216ca
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js
@@ -0,0 +1,298 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrEditIssue, allowRemovedRestrictions} from './mr-edit-issue.js';
+import {clientLoggerFake} from 'shared/test/fakes.js';
+
+let element;
+let clock;
+
+describe('mr-edit-issue', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-edit-issue');
+ document.body.appendChild(element);
+ sinon.stub(prpcClient, 'call');
+
+ element.clientLogger = clientLoggerFake();
+ clock = sinon.useFakeTimers();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+
+ clock.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditIssue);
+ });
+
+ it('scrolls into view on #makechanges hash', async () => {
+ await element.updateComplete;
+
+ const header = element.querySelector('#makechanges');
+ sinon.stub(header, 'scrollIntoView');
+
+ element.focusId = 'makechanges';
+ await element.updateComplete;
+
+ assert.isTrue(header.scrollIntoView.calledOnce);
+
+ header.scrollIntoView.restore();
+ });
+
+ it('shows snackbar and resets form when editing finishes', async () => {
+ sinon.stub(element, 'reset');
+ sinon.stub(element, '_showCommentAddedSnackbar');
+
+ element.updatingIssue = true;
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element._showCommentAddedSnackbar);
+ sinon.assert.notCalled(element.reset);
+
+ element.updatingIssue = false;
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._showCommentAddedSnackbar);
+ sinon.assert.calledOnce(element.reset);
+ });
+
+ it('does not show snackbar or reset form on edit error', async () => {
+ sinon.stub(element, 'reset');
+ sinon.stub(element, '_showCommentAddedSnackbar');
+
+ element.updatingIssue = true;
+ await element.updateComplete;
+
+ element.updateError = 'The save failed';
+ element.updatingIssue = false;
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element._showCommentAddedSnackbar);
+ sinon.assert.notCalled(element.reset);
+ });
+
+ it('shows current status even if not defined for project', async () => {
+ await element.updateComplete;
+
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ assert.deepEqual(editMetadata.statuses, []);
+
+ element.projectConfig = {statusDefs: [
+ {status: 'hello'},
+ {status: 'world'},
+ ]};
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'hello'},
+ {status: 'world'},
+ ]);
+
+ element.issue = {
+ statusRef: {status: 'hello'},
+ };
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'hello'},
+ {status: 'world'},
+ ]);
+
+ element.issue = {
+ statusRef: {status: 'weirdStatus'},
+ };
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'weirdStatus'},
+ {status: 'hello'},
+ {status: 'world'},
+ ]);
+ });
+
+ it('ignores deprecated statuses, unless used on current issue', async () => {
+ await element.updateComplete;
+
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ assert.deepEqual(editMetadata.statuses, []);
+
+ element.projectConfig = {statusDefs: [
+ {status: 'new'},
+ {status: 'accepted', deprecated: false},
+ {status: 'compiling', deprecated: true},
+ ]};
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'new'},
+ {status: 'accepted', deprecated: false},
+ ]);
+
+
+ element.issue = {
+ statusRef: {status: 'compiling'},
+ };
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'compiling'},
+ {status: 'new'},
+ {status: 'accepted', deprecated: false},
+ ]);
+ });
+
+ it('filter out empty or deleted user owners', () => {
+ assert.equal(
+ element._ownerDisplayName({displayName: 'a_deleted_user'}),
+ '');
+ assert.equal(
+ element._ownerDisplayName({
+ displayName: 'test@example.com',
+ userId: '1234',
+ }),
+ 'test@example.com');
+ });
+
+ it('logs issue-update metrics', async () => {
+ await element.updateComplete;
+
+ const editMetadata = element.querySelector('mr-edit-metadata');
+
+ sinon.stub(editMetadata, 'delta').get(() => ({summary: 'test'}));
+
+ await element.save();
+
+ sinon.assert.calledOnce(element.clientLogger.logStart);
+ sinon.assert.calledWith(element.clientLogger.logStart,
+ 'issue-update', 'computer-time');
+
+ // Simulate a response updating the UI.
+ element.issue = {summary: 'test'};
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.clientLogger.logEnd);
+ sinon.assert.calledWith(element.clientLogger.logEnd,
+ 'issue-update', 'computer-time', 120 * 1000);
+ });
+
+ it('presubmits issue on metadata change', async () => {
+ element.issueRef = {};
+
+ await element.updateComplete;
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ editMetadata.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ delta: {
+ summary: 'Summary',
+ },
+ },
+ }));
+
+ // Wait for debouncer.
+ clock.tick(element.presubmitDebounceTimeOut + 1);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'PresubmitIssue',
+ {issueDelta: {summary: 'Summary'}, issueRef: {}});
+ });
+
+ it('presubmits issue on comment change', async () => {
+ element.issueRef = {};
+
+ await element.updateComplete;
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ editMetadata.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ delta: {},
+ commentContent: 'test',
+ },
+ }));
+
+ // Wait for debouncer.
+ clock.tick(element.presubmitDebounceTimeOut + 1);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'PresubmitIssue',
+ {issueDelta: {}, issueRef: {}});
+ });
+
+
+ it('does not presubmit issue when no changes', () => {
+ element._presubmitIssue({});
+
+ sinon.assert.notCalled(prpcClient.call);
+ });
+
+ it('editing form runs _presubmitIssue debounced', async () => {
+ sinon.stub(element, '_presubmitIssue');
+
+ await element.updateComplete;
+
+ // User makes some changes.
+ const comment = element.querySelector('#commentText');
+ comment.value = 'Value';
+ comment.dispatchEvent(new Event('keyup'));
+
+ clock.tick(5);
+
+ // User makes more changes before debouncer timeout is done.
+ comment.value = 'more changes';
+ comment.dispatchEvent(new Event('keyup'));
+
+ clock.tick(10);
+
+ sinon.assert.notCalled(element._presubmitIssue);
+
+ // Wait for debouncer.
+ clock.tick(element.presubmitDebounceTimeOut + 1);
+
+ sinon.assert.calledOnce(element._presubmitIssue);
+ });
+});
+
+describe('allowRemovedRestrictions', () => {
+ beforeEach(() => {
+ sinon.stub(window, 'confirm');
+ });
+
+ afterEach(() => {
+ window.confirm.restore();
+ });
+
+ it('returns true if no restrictions removed', () => {
+ assert.isTrue(allowRemovedRestrictions([
+ {label: 'not-restricted'},
+ {label: 'fine'},
+ ]));
+ });
+
+ it('returns false if restrictions removed and confirmation denied', () => {
+ window.confirm.returns(false);
+ assert.isFalse(allowRemovedRestrictions([
+ {label: 'not-restricted'},
+ {label: 'restrict-view-people'},
+ ]));
+ });
+
+ it('returns true if restrictions removed and confirmation accepted', () => {
+ window.confirm.returns(true);
+ assert.isTrue(allowRemovedRestrictions([
+ {label: 'not-restricted'},
+ {label: 'restrict-view-people'},
+ ]));
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js
new file mode 100644
index 0000000..804c8d1
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js
@@ -0,0 +1,1188 @@
+// 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} from 'lit-element';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/framework/mr-upload/mr-upload.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import 'elements/chops/chops-checkbox/chops-checkbox.js';
+import 'elements/chops/chops-chip/chops-chip.js';
+import 'elements/framework/mr-error/mr-error.js';
+import 'elements/framework/mr-warning/mr-warning.js';
+import 'elements/help/mr-cue/mr-cue.js';
+import 'react/mr-react-autocomplete.tsx';
+import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
+import {store, connectStore} from 'reducers/base.js';
+import {UserInputError} from 'shared/errors.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import {displayNameToUserRef, labelStringToRef, componentStringToRef,
+ componentRefsToStrings, issueStringToRef, issueStringToBlockingRef,
+ issueRefToString, issueRefsToStrings, filteredUserDisplayNames,
+ valueToFieldValue, fieldDefToName,
+} from 'shared/convertersV0.js';
+import {arrayDifference, isEmptyObject, equalsIgnoreCase} from 'shared/helpers.js';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as permissions from 'reducers/permissions.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import '../mr-edit-field/mr-edit-field.js';
+import '../mr-edit-field/mr-edit-status.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
+ ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
+ ISSUE_EDIT_CC_PERMISSION,
+} from 'shared/consts/permissions.js';
+import {fieldDefsWithGroup, fieldDefsWithoutGroup, valuesForField,
+ HARDCODED_FIELD_GROUPS} from 'shared/metadata-helpers.js';
+import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+import {MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+
+
+
+/**
+ * `<mr-edit-metadata>`
+ *
+ * Editing form for either an approval or the overall issue.
+ *
+ */
+export class MrEditMetadata extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <style>
+ ${MD_PREVIEW_STYLES}
+ ${MD_STYLES}
+ mr-edit-metadata {
+ display: block;
+ font-size: var(--chops-main-font-size);
+ }
+ mr-edit-metadata.edit-actions-right .edit-actions {
+ flex-direction: row-reverse;
+ text-align: right;
+ }
+ mr-edit-metadata.edit-actions-right .edit-actions chops-checkbox {
+ text-align: left;
+ }
+ .edit-actions chops-checkbox {
+ max-width: 200px;
+ margin-top: 2px;
+ flex-grow: 2;
+ text-align: right;
+ }
+ .edit-actions {
+ width: 100%;
+ max-width: 500px;
+ margin: 0.5em 0;
+ text-align: left;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
+ .edit-actions chops-button {
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ .edit-actions .emphasized {
+ margin-left: 0;
+ }
+ input {
+ box-sizing: border-box;
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ font-size: var(--chops-main-font-size);
+ }
+ mr-upload {
+ margin-bottom: 0.25em;
+ }
+ textarea {
+ font-family: var(--mr-toggled-font-family);
+ width: 100%;
+ margin: 0.25em 0;
+ box-sizing: border-box;
+ border: var(--chops-accessible-border);
+ height: 8em;
+ transition: height 0.1s ease-in-out;
+ padding: 0.5em 4px;
+ grid-column-start: 1;
+ grid-column-end: 2;
+ }
+ button.toggle {
+ background: none;
+ color: var(--chops-link-color);
+ border: 0;
+ width: 100%;
+ padding: 0.25em 0;
+ text-align: left;
+ }
+ button.toggle:hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ .presubmit-derived {
+ color: gray;
+ font-style: italic;
+ text-decoration-line: underline;
+ text-decoration-style: dotted;
+ }
+ .presubmit-derived-header {
+ color: gray;
+ font-weight: bold;
+ }
+ .discard-button {
+ margin-right: 16px;
+ margin-left: 16px;
+ }
+ .group {
+ width: 100%;
+ border: 1px solid hsl(0, 0%, 83%);
+ grid-column: 1 / -1;
+ margin: 0;
+ margin-bottom: 0.5em;
+ padding: 0;
+ padding-bottom: 0.5em;
+ }
+ .group legend {
+ margin-left: 130px;
+ }
+ .group-title {
+ text-align: center;
+ font-style: oblique;
+ margin-top: 4px;
+ margin-bottom: -8px;
+ }
+ .star-line {
+ display: flex;
+ align-items: center;
+ background: var(--chops-notice-bubble-bg);
+ border: var(--chops-notice-border);
+ justify-content: flex-start;
+ margin-top: 4px;
+ padding: 2px 4px 2px 8px;
+ }
+ mr-issue-star {
+ margin-right: 4px;
+ }
+ </style>
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <form id="editForm"
+ @submit=${this._save}
+ @keydown=${this._saveOnCtrlEnter}
+ >
+ <mr-cue cuePrefName=${cueNames.CODE_OF_CONDUCT}></mr-cue>
+ ${this._renderStarLine()}
+ <textarea
+ id="commentText"
+ placeholder="Add a comment"
+ @keyup=${this._processChanges}
+ aria-label="Comment"
+ ></textarea>
+ ${(this._renderMarkdown)
+ ? html`
+ <div class="markdown-preview preview-height-comment">
+ <div class="markdown">
+ ${unsafeHTML(renderMarkdown(this.getCommentContent()))}
+ </div>
+ </div>`: ''}
+ <mr-upload
+ ?hidden=${this.disableAttachments}
+ @change=${this._processChanges}
+ ></mr-upload>
+ <div class="input-grid">
+ ${this._renderEditFields()}
+ ${this._renderErrorsAndWarnings()}
+
+ <span></span>
+ <div class="edit-actions">
+ <chops-button
+ @click=${this._save}
+ class="save-changes emphasized"
+ ?disabled=${this.disabled}
+ title="Save changes (Ctrl+Enter / \u2318+Enter)"
+ >
+ Save changes
+ </chops-button>
+ <chops-button
+ @click=${this.discard}
+ class="de-emphasized discard-button"
+ ?disabled=${this.disabled}
+ >
+ Discard
+ </chops-button>
+
+ <chops-checkbox
+ id="sendEmail"
+ @checked-change=${this._sendEmailChecked}
+ ?checked=${this.sendEmail}
+ >Send email</chops-checkbox>
+ </div>
+
+ ${!this.isApproval ? this._renderPresubmitChanges() : ''}
+ </div>
+ </form>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderStarLine() {
+ if (this._canEditIssue || this.isApproval) return '';
+
+ return html`
+ <div class="star-line">
+ <mr-issue-star
+ .issueRef=${this.issueRef}
+ ></mr-issue-star>
+ <span>
+ ${this.isStarred ? `
+ You have voted for this issue and will receive notifications.
+ ` : `
+ Star this issue instead of commenting "+1 Me too!" to add a vote
+ and get notifications.`}
+ </span>
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderPresubmitChanges() {
+ const {derivedCcs, derivedLabels} = this.presubmitResponse || {};
+ const hasCcs = derivedCcs && derivedCcs.length;
+ const hasLabels = derivedLabels && derivedLabels.length;
+ const hasDerivedValues = hasCcs || hasLabels;
+ return html`
+ ${hasDerivedValues ? html`
+ <span></span>
+ <div class="presubmit-derived-header">
+ Filter rules and components will add
+ </div>
+ ` : ''}
+
+ ${hasCcs? html`
+ <label
+ for="derived-ccs"
+ class="presubmit-derived-header"
+ >CC:</label>
+ <div id="derived-ccs">
+ ${derivedCcs.map((cc) => html`
+ <span
+ title=${cc.why}
+ class="presubmit-derived"
+ >${cc.value}</span>
+ `)}
+ </div>
+ ` : ''}
+
+ ${hasLabels ? html`
+ <label
+ for="derived-labels"
+ class="presubmit-derived-header"
+ >Labels:</label>
+ <div id="derived-labels">
+ ${derivedLabels.map((label) => html`
+ <span
+ title=${label.why}
+ class="presubmit-derived"
+ >${label.value}</span>
+ `)}
+ </div>
+ ` : ''}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderErrorsAndWarnings() {
+ const presubmitResponse = this.presubmitResponse || {};
+ const presubmitWarnings = presubmitResponse.warnings || [];
+ const presubmitErrors = presubmitResponse.errors || [];
+ return (this.error || presubmitWarnings.length || presubmitErrors.length) ?
+ html`
+ <span></span>
+ <div>
+ ${presubmitWarnings.map((warning) => html`
+ <mr-warning title=${warning.why}>${warning.value}</mr-warning>
+ `)}
+ <!-- TODO(ehmaldonado): Look into blocking submission on presubmit
+ -->
+ ${presubmitErrors.map((error) => html`
+ <mr-error title=${error.why}>${error.value}</mr-error>
+ `)}
+ ${this.error ? html`
+ <mr-error>${this.error}</mr-error>` : ''}
+ </div>
+ ` : '';
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderEditFields() {
+ if (this.isApproval) {
+ return html`
+ ${this._renderStatus()}
+ ${this._renderApprovers()}
+ ${this._renderFieldDefs()}
+
+ ${this._renderNicheFieldToggle()}
+ `;
+ }
+
+ return html`
+ ${this._canEditSummary ? this._renderSummary() : ''}
+ ${this._canEditStatus ? this._renderStatus() : ''}
+ ${this._canEditOwner ? this._renderOwner() : ''}
+ ${this._canEditCC ? this._renderCC() : ''}
+ ${this._canEditIssue ? html`
+ ${this._renderComponents()}
+
+ ${this._renderFieldDefs()}
+ ${this._renderRelatedIssues()}
+ ${this._renderLabels()}
+
+ ${this._renderNicheFieldToggle()}
+ ` : ''}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderSummary() {
+ return html`
+ <label for="summaryInput">Summary:</label>
+ <input
+ id="summaryInput"
+ value=${this.summary}
+ @keyup=${this._processChanges}
+ />
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderOwner() {
+ const ownerPresubmit = this._ownerPresubmit;
+ return html`
+ <label for="ownerInput">
+ ${ownerPresubmit.message ? html`
+ <i
+ class=${`material-icons inline-${ownerPresubmit.icon}`}
+ title=${ownerPresubmit.message}
+ >${ownerPresubmit.icon}</i>
+ ` : ''}
+ Owner:
+ </label>
+ <mr-react-autocomplete
+ label="ownerInput"
+ vocabularyName="owner"
+ .placeholder=${ownerPresubmit.placeholder}
+ .value=${this._values.owner}
+ .onChange=${this._changeHandlers.owner}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderCC() {
+ return html`
+ <label for="ccInput">CC:</label>
+ <mr-react-autocomplete
+ label="ccInput"
+ vocabularyName="member"
+ .multiple=${true}
+ .fixedValues=${this._derivedCCs}
+ .value=${this._values.cc}
+ .onChange=${this._changeHandlers.cc}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderComponents() {
+ return html`
+ <label for="componentsInput">Components:</label>
+ <mr-react-autocomplete
+ label="componentsInput"
+ vocabularyName="component"
+ .multiple=${true}
+ .value=${this._values.components}
+ .onChange=${this._changeHandlers.components}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderApprovers() {
+ return this.hasApproverPrivileges && this.isApproval ? html`
+ <label for="approversInput_react">Approvers:</label>
+ <mr-edit-field
+ id="approversInput"
+ label="approversInput_react"
+ .type=${'USER_TYPE'}
+ .initialValues=${filteredUserDisplayNames(this.approvers)}
+ .name=${'approver'}
+ .acType=${'member'}
+ @change=${this._processChanges}
+ multi
+ ></mr-edit-field>
+ ` : '';
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderStatus() {
+ return this.statuses && this.statuses.length ? html`
+ <label for="statusInput">Status:</label>
+
+ <mr-edit-status
+ id="statusInput"
+ .initialStatus=${this.status}
+ .statuses=${this.statuses}
+ .mergedInto=${issueRefToString(this.mergedInto, this.projectName)}
+ ?isApproval=${this.isApproval}
+ @change=${this._processChanges}
+ ></mr-edit-status>
+ ` : '';
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderFieldDefs() {
+ return html`
+ ${fieldDefsWithGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((group) => html`
+ <fieldset class="group">
+ <legend>${group.groupName}</legend>
+ <div class="input-grid">
+ ${group.fieldDefs.map((field) => this._renderCustomField(field))}
+ </div>
+ </fieldset>
+ `)}
+
+ ${fieldDefsWithoutGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((field) => this._renderCustomField(field))}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderRelatedIssues() {
+ return html`
+ <label for="blockedOnInput">BlockedOn:</label>
+ <mr-react-autocomplete
+ label="blockedOnInput"
+ vocabularyName="component"
+ .multiple=${true}
+ .value=${this._values.blockedOn}
+ .onChange=${this._changeHandlers.blockedOn}
+ ></mr-react-autocomplete>
+
+ <label for="blockingInput">Blocking:</label>
+ <mr-react-autocomplete
+ label="blockingInput"
+ vocabularyName="component"
+ .multiple=${true}
+ .value=${this._values.blocking}
+ .onChange=${this._changeHandlers.blocking}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderLabels() {
+ return html`
+ <label for="labelsInput">Labels:</label>
+ <mr-react-autocomplete
+ label="labelsInput"
+ vocabularyName="label"
+ .multiple=${true}
+ .fixedValues=${this.derivedLabels}
+ .value=${this._values.labels}
+ .onChange=${this._changeHandlers.labels}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @param {FieldDef} field The custom field beinf rendered.
+ * @private
+ */
+ _renderCustomField(field) {
+ if (!field || !field.fieldRef) return '';
+ const userCanEdit = this._userCanEdit(field);
+ const {fieldRef, isNiche, docstring, isMultivalued} = field;
+ const isHidden = (!this.showNicheFields && isNiche) || !userCanEdit;
+
+ let acType;
+ if (fieldRef.type === fieldTypes.USER_TYPE) {
+ acType = isMultivalued ? 'member' : 'owner';
+ }
+ return html`
+ <label
+ ?hidden=${isHidden}
+ for=${this._idForField(fieldRef.fieldName) + '_react'}
+ title=${docstring}
+ >
+ ${fieldRef.fieldName}:
+ </label>
+ <mr-edit-field
+ ?hidden=${isHidden}
+ id=${this._idForField(fieldRef.fieldName)}
+ .label=${this._idForField(fieldRef.fieldName) + '_react'}
+ .name=${fieldRef.fieldName}
+ .type=${fieldRef.type}
+ .options=${this._optionsForField(this.optionsPerEnumField, this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
+ .initialValues=${valuesForField(this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
+ .acType=${acType}
+ ?multi=${isMultivalued}
+ @change=${this._processChanges}
+ ></mr-edit-field>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderNicheFieldToggle() {
+ return this._nicheFieldCount ? html`
+ <span></span>
+ <button type="button" class="toggle" @click=${this.toggleNicheFields}>
+ <span ?hidden=${this.showNicheFields}>
+ Show all fields (${this._nicheFieldCount} currently hidden)
+ </span>
+ <span ?hidden=${!this.showNicheFields}>
+ Hide niche fields (${this._nicheFieldCount} currently shown)
+ </span>
+ </button>
+ ` : '';
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ fieldDefs: {type: Array},
+ formName: {type: String},
+ approvers: {type: Array},
+ setter: {type: Object},
+ summary: {type: String},
+ cc: {type: Array},
+ components: {type: Array},
+ status: {type: String},
+ statuses: {type: Array},
+ blockedOn: {type: Array},
+ blocking: {type: Array},
+ mergedInto: {type: Object},
+ ownerName: {type: String},
+ labelNames: {type: Array},
+ derivedLabels: {type: Array},
+ _permissions: {type: Array},
+ phaseName: {type: String},
+ projectConfig: {type: Object},
+ projectName: {type: String},
+ isApproval: {type: Boolean},
+ isStarred: {type: Boolean},
+ issuePermissions: {type: Object},
+ issueRef: {type: Object},
+ hasApproverPrivileges: {type: Boolean},
+ showNicheFields: {type: Boolean},
+ disableAttachments: {type: Boolean},
+ error: {type: String},
+ sendEmail: {type: Boolean},
+ presubmitResponse: {type: Object},
+ fieldValueMap: {type: Object},
+ issueType: {type: String},
+ optionsPerEnumField: {type: String},
+ fieldGroups: {type: Object},
+ prefs: {type: Object},
+ saving: {type: Boolean},
+ isDirty: {type: Boolean},
+ _values: {type: Object},
+ _initialValues: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.summary = '';
+ this.ownerName = '';
+ this.sendEmail = true;
+ this.mergedInto = {};
+ this.issueRef = {};
+ this.fieldGroups = HARDCODED_FIELD_GROUPS;
+
+ this._permissions = {};
+ this.saving = false;
+ this.isDirty = false;
+ this.prefs = {};
+ this._values = {};
+ this._initialValues = {};
+
+ // Memoize change handlers so property updates don't cause excess rerenders.
+ this._changeHandlers = {
+ owner: this._onChange.bind(this, 'owner'),
+ cc: this._onChange.bind(this, 'cc'),
+ components: this._onChange.bind(this, 'components'),
+ labels: this._onChange.bind(this, 'labels'),
+ blockedOn: this._onChange.bind(this, 'blockedOn'),
+ blocking: this._onChange.bind(this, 'blocking'),
+ };
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ firstUpdated() {
+ this.hasRendered = true;
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('ownerName') || changedProperties.has('cc')
+ || changedProperties.has('components')
+ || changedProperties.has('labelNames')
+ || changedProperties.has('blockedOn')
+ || changedProperties.has('blocking')
+ || changedProperties.has('projectName')) {
+ this._initialValues.owner = this.ownerName;
+ this._initialValues.cc = this._ccNames;
+ this._initialValues.components = componentRefsToStrings(this.components);
+ this._initialValues.labels = this.labelNames;
+ this._initialValues.blockedOn = issueRefsToStrings(this.blockedOn, this.projectName);
+ this._initialValues.blocking = issueRefsToStrings(this.blocking, this.projectName);
+
+ this._values = {...this._initialValues};
+ }
+ }
+
+ /**
+ * Getter for checking if the user has Markdown enabled.
+ * @return {boolean} Whether Markdown preview should be rendered or not.
+ */
+ get _renderMarkdown() {
+ if (!this.getCommentContent()) {
+ return false;
+ }
+ const enabled = this.prefs.get('render_markdown');
+ return shouldRenderMarkdown({project: this.projectName, enabled});
+ }
+
+ /**
+ * @return {boolean} Whether the "Save changes" button is disabled.
+ */
+ get disabled() {
+ return !this.isDirty || this.saving;
+ }
+
+ /**
+ * Set isDirty to a property instead of only using a getter to cause
+ * lit-element to re-render when dirty state change.
+ */
+ _updateIsDirty() {
+ if (!this.hasRendered) return;
+
+ const commentContent = this.getCommentContent();
+ const attachmentsElement = this.querySelector('mr-upload');
+ this.isDirty = !isEmptyObject(this.delta) || Boolean(commentContent) ||
+ attachmentsElement.hasAttachments;
+ }
+
+ get _nicheFieldCount() {
+ const fieldDefs = this.fieldDefs || [];
+ return fieldDefs.reduce((acc, fd) => acc + (fd.isNiche | 0), 0);
+ }
+
+ get _canEditIssue() {
+ const issuePermissions = this.issuePermissions || [];
+ return issuePermissions.includes(ISSUE_EDIT_PERMISSION);
+ }
+
+ get _canEditSummary() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_SUMMARY_PERMISSION);
+ }
+
+ get _canEditStatus() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_STATUS_PERMISSION);
+ }
+
+ get _canEditOwner() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_OWNER_PERMISSION);
+ }
+
+ get _canEditCC() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_CC_PERMISSION);
+ }
+
+ /**
+ * @return {Array<string>}
+ */
+ get _ccNames() {
+ const users = this.cc || [];
+ return filteredUserDisplayNames(users.filter((u) => !u.isDerived));
+ }
+
+ get _derivedCCs() {
+ const users = this.cc || [];
+ return filteredUserDisplayNames(users.filter((u) => u.isDerived));
+ }
+
+ get _ownerPresubmit() {
+ const response = this.presubmitResponse;
+ if (!response) return {};
+
+ const ownerView = {message: '', placeholder: '', icon: ''};
+
+ if (response.ownerAvailability) {
+ ownerView.message = response.ownerAvailability;
+ ownerView.icon = 'warning';
+ } else if (response.derivedOwners && response.derivedOwners.length) {
+ ownerView.placeholder = response.derivedOwners[0].value;
+ ownerView.message = response.derivedOwners[0].why;
+ ownerView.icon = 'info';
+ }
+ return ownerView;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.fieldValueMap = issueV0.fieldValueMap(state);
+ this.issueType = issueV0.type(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this._permissions = permissions.byName(state);
+ this.presubmitResponse = issueV0.presubmitResponse(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.projectName = issueV0.viewedIssueRef(state).projectName;
+ this.issuePermissions = issueV0.permissions(state);
+ this.optionsPerEnumField = projectV0.optionsPerEnumField(state);
+ // Access boolean value from allStarredIssues
+ const starredIssues = issueV0.starredIssues(state);
+ this.isStarred = starredIssues.has(issueRefToString(this.issueRef));
+ this.prefs = userV0.prefs(state);
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ store.dispatch(ui.reportDirtyForm(this.formName, false));
+ }
+
+ /**
+ * Resets the edit form values to their default values.
+ */
+ async reset() {
+ this._values = {...this._initialValues};
+
+ const form = this.querySelector('#editForm');
+ if (!form) return;
+
+ form.reset();
+ const statusInput = this.querySelector('#statusInput');
+ if (statusInput) {
+ statusInput.reset();
+ }
+
+ // Since custom elements containing <input> elements have the inputs
+ // wrapped in ShadowDOM, those inputs don't get reset with the rest of
+ // the form. Haven't been able to figure out a way to replicate form reset
+ // behavior with custom input elements.
+ if (this.isApproval) {
+ if (this.hasApproverPrivileges) {
+ const approversInput = this.querySelector(
+ '#approversInput');
+ if (approversInput) {
+ approversInput.reset();
+ }
+ }
+ }
+ this.querySelectorAll('mr-edit-field').forEach((el) => {
+ el.reset();
+ });
+
+ const uploader = this.querySelector('mr-upload');
+ if (uploader) {
+ uploader.reset();
+ }
+
+ // TODO(dtu, zhangtiff): Remove once all form fields are controlled.
+ await this.updateComplete;
+
+ this._processChanges();
+ }
+
+ /**
+ * @param {MouseEvent|SubmitEvent} event
+ * @private
+ */
+ _save(event) {
+ event.preventDefault();
+ this.save();
+ }
+
+ /**
+ * Users may use either Ctrl+Enter or Command+Enter to save an issue edit
+ * while the issue edit form is focused.
+ * @param {KeyboardEvent} event
+ * @private
+ */
+ _saveOnCtrlEnter(event) {
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+ event.preventDefault();
+ this.save();
+ }
+ }
+
+ /**
+ * Tells the parent to save the current edited values in the form.
+ * @fires CustomEvent#save
+ */
+ save() {
+ this.dispatchEvent(new CustomEvent('save'));
+ }
+
+ /**
+ * Tells the parent component that the user is trying to discard the form,
+ * if they confirm that that's what they're doing. The parent decides what
+ * to do in order to quit the editing session.
+ * @fires CustomEvent#discard
+ */
+ discard() {
+ const isDirty = this.isDirty;
+ if (!isDirty || confirm('Discard your changes?')) {
+ this.dispatchEvent(new CustomEvent('discard'));
+ }
+ }
+
+ /**
+ * Focuses the comment form.
+ */
+ async focus() {
+ await this.updateComplete;
+ this.querySelector('#commentText').focus();
+ }
+
+ /**
+ * Retrieves the value of the comment that the user added from the DOM.
+ * @return {string}
+ */
+ getCommentContent() {
+ if (!this.querySelector('#commentText')) {
+ return '';
+ }
+ return this.querySelector('#commentText').value;
+ }
+
+ async getAttachments() {
+ try {
+ return await this.querySelector('mr-upload').loadFiles();
+ } catch (e) {
+ this.error = `Error while loading file for attachment: ${e.message}`;
+ }
+ }
+
+ /**
+ * @param {FieldDef} field
+ * @return {boolean}
+ * @private
+ */
+ _userCanEdit(field) {
+ const fieldName = fieldDefToName(this.projectName, field);
+ if (!this._permissions[fieldName] ||
+ !this._permissions[fieldName].permissions) return false;
+ const userPerms = this._permissions[fieldName].permissions;
+ return userPerms.includes(permissions.FIELD_DEF_VALUE_EDIT);
+ }
+
+ /**
+ * Shows or hides custom fields with the "isNiche" attribute set to true.
+ */
+ toggleNicheFields() {
+ this.showNicheFields = !this.showNicheFields;
+ }
+
+ /**
+ * @return {IssueDelta}
+ * @throws {UserInputError}
+ */
+ get delta() {
+ try {
+ this.error = '';
+ return this._getDelta();
+ } catch (e) {
+ if (!(e instanceof UserInputError)) throw e;
+ this.error = e.message;
+ return {};
+ }
+ }
+
+ /**
+ * Generates a change between the initial Issue state and what the user
+ * inputted.
+ * @return {IssueDelta}
+ */
+ _getDelta() {
+ let result = {};
+
+ const {projectName, localId} = this.issueRef;
+
+ const statusInput = this.querySelector('#statusInput');
+ if (this._canEditStatus && statusInput) {
+ const statusDelta = statusInput.delta;
+ if (statusDelta.mergedInto) {
+ result.mergedIntoRef = issueStringToBlockingRef(
+ {projectName, localId}, statusDelta.mergedInto);
+ }
+ if (statusDelta.status) {
+ result.status = statusDelta.status;
+ }
+ }
+
+ if (this.isApproval) {
+ if (this._canEditIssue && this.hasApproverPrivileges) {
+ result = {
+ ...result,
+ ...this._changedValuesDom(
+ 'approvers', 'approverRefs', displayNameToUserRef),
+ };
+ }
+ } else {
+ // TODO(zhangtiff): Consider representing baked-in fields such as owner,
+ // cc, and status similarly to custom fields to reduce repeated code.
+
+ if (this._canEditSummary) {
+ const summaryInput = this.querySelector('#summaryInput');
+ if (summaryInput) {
+ const newSummary = summaryInput.value;
+ if (newSummary !== this.summary) {
+ result.summary = newSummary;
+ }
+ }
+ }
+
+ if (this._values.owner !== this._initialValues.owner) {
+ result.ownerRef = displayNameToUserRef(this._values.owner);
+ }
+
+ const blockerAddFn = (refString) =>
+ issueStringToBlockingRef({projectName, localId}, refString);
+ const blockerRemoveFn = (refString) =>
+ issueStringToRef(refString, projectName);
+
+ result = {
+ ...result,
+ ...this._changedValuesControlled(
+ 'cc', 'ccRefs', displayNameToUserRef),
+ ...this._changedValuesControlled(
+ 'components', 'compRefs', componentStringToRef),
+ ...this._changedValuesControlled(
+ 'labels', 'labelRefs', labelStringToRef),
+ ...this._changedValuesControlled(
+ 'blockedOn', 'blockedOnRefs', blockerAddFn, blockerRemoveFn),
+ ...this._changedValuesControlled(
+ 'blocking', 'blockingRefs', blockerAddFn, blockerRemoveFn),
+ };
+ }
+
+ if (this._canEditIssue) {
+ const fieldDefs = this.fieldDefs || [];
+ fieldDefs.forEach(({fieldRef}) => {
+ const {fieldValsAdd = [], fieldValsRemove = []} =
+ this._changedValuesDom(fieldRef.fieldName, 'fieldVals',
+ valueToFieldValue.bind(null, fieldRef));
+
+ // Because multiple custom fields share the same "fieldVals" key in
+ // delta, we hav to make sure to concatenate updated delta values with
+ // old delta values.
+ if (fieldValsAdd.length) {
+ result.fieldValsAdd = [...(result.fieldValsAdd || []),
+ ...fieldValsAdd];
+ }
+
+ if (fieldValsRemove.length) {
+ result.fieldValsRemove = [...(result.fieldValsRemove || []),
+ ...fieldValsRemove];
+ }
+ });
+ }
+
+ return result;
+ }
+
+ /**
+ * Computes delta values for a controlled input.
+ * @param {string} fieldName The key in the values property to retrieve data.
+ * from.
+ * @param {string} responseKey The key in the delta Object that changes will be
+ * saved in.
+ * @param {function(string): any} addFn A function to specify how to format
+ * the message for a given added field.
+ * @param {function(string): any} removeFn A function to specify how to format
+ * the message for a given removed field.
+ * @return {Object} delta fragment for added and removed values.
+ */
+ _changedValuesControlled(fieldName, responseKey, addFn, removeFn) {
+ const values = this._values[fieldName];
+ const initialValues = this._initialValues[fieldName];
+
+ const valuesAdd = arrayDifference(values, initialValues, equalsIgnoreCase);
+ const valuesRemove =
+ arrayDifference(initialValues, values, equalsIgnoreCase);
+
+ return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
+ }
+
+ /**
+ * Gets changes values when reading from a legacy <mr-edit-field> element.
+ * @param {string} fieldName Name of the form input we're checking values on.
+ * @param {string} responseKey The key in the delta Object that changes will be
+ * saved in.
+ * @param {function(string): any} addFn A function to specify how to format
+ * the message for a given added field.
+ * @param {function(string): any} removeFn A function to specify how to format
+ * the message for a given removed field.
+ * @return {Object} delta fragment for added and removed values.
+ */
+ _changedValuesDom(fieldName, responseKey, addFn, removeFn) {
+ const input = this.querySelector(`#${this._idForField(fieldName)}`);
+ if (!input) return;
+
+ const valuesAdd = input.getValuesAdded();
+ const valuesRemove = input.getValuesRemoved();
+
+ return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
+ }
+
+ /**
+ * Shared helper function for computing added and removed values for a
+ * single field in a delta.
+ * @param {Array<string>} valuesAdd The added values. For example, new CCed
+ * users.
+ * @param {Array<string>} valuesRemove Values that were removed in this edit.
+ * @param {string} responseKey The key in the delta Object that changes will be
+ * saved in.
+ * @param {function(string): any} addFn A function to specify how to format
+ * the message for a given added field.
+ * @param {function(string): any} removeFn A function to specify how to format
+ * the message for a given removed field.
+ * @return {Object} delta fragment for added and removed values.
+ */
+ _changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn) {
+ const delta = {};
+
+ if (valuesAdd && valuesAdd.length) {
+ delta[responseKey + 'Add'] = valuesAdd.map(addFn);
+ }
+
+ if (valuesRemove && valuesRemove.length) {
+ delta[responseKey + 'Remove'] = valuesRemove.map(removeFn || addFn);
+ }
+
+ return delta;
+ }
+
+ /**
+ * Generic onChange handler to be bound to each form field.
+ * @param {string} key Unique name for the form field we're binding this
+ * handler to. For example, 'owner', 'cc', or the name of a custom field.
+ * @param {Event} event
+ * @param {string|Array<string>} value The new form value.
+ * @param {*} _reason
+ */
+ _onChange(key, event, value, _reason) {
+ this._values = {...this._values, [key]: value};
+ this._processChanges(event);
+ }
+
+ /**
+ * Event handler for running filter rules presubmit logic.
+ * @param {Event} e
+ */
+ _processChanges(e) {
+ if (e instanceof KeyboardEvent) {
+ if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+ }
+ this._updateIsDirty();
+
+ store.dispatch(ui.reportDirtyForm(this.formName, this.isDirty));
+
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ delta: this.delta,
+ commentContent: this.getCommentContent(),
+ },
+ }));
+ }
+
+ _idForField(name) {
+ return `${name}Input`;
+ }
+
+ _optionsForField(optionsPerEnumField, fieldValueMap, fieldName, phaseName) {
+ if (!optionsPerEnumField || !fieldName) return [];
+ const key = fieldName.toLowerCase();
+ if (!optionsPerEnumField.has(key)) return [];
+ const options = [...optionsPerEnumField.get(key)];
+ const values = valuesForField(fieldValueMap, fieldName, phaseName);
+ values.forEach((v) => {
+ const optionExists = options.find(
+ (opt) => equalsIgnoreCase(opt.optionName, v));
+ if (!optionExists) {
+ // Note that enum fields which are not explicitly defined can be set,
+ // such as in the case when an issue is moved.
+ options.push({optionName: v});
+ }
+ });
+ return options;
+ }
+
+ _sendEmailChecked(evt) {
+ this.sendEmail = evt.detail.checked;
+ }
+}
+
+customElements.define('mr-edit-metadata', MrEditMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js
new file mode 100644
index 0000000..2e4554f
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js
@@ -0,0 +1,1078 @@
+// 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 {fireEvent} from '@testing-library/react';
+
+import {MrEditMetadata} from './mr-edit-metadata.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
+ ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
+ ISSUE_EDIT_CC_PERMISSION,
+} from 'shared/consts/permissions.js';
+import {FIELD_DEF_VALUE_EDIT} from 'reducers/permissions.js';
+import {store, resetState} from 'reducers/base.js';
+import {enterInput} from 'shared/test/helpers.js';
+
+let element;
+
+xdescribe('mr-edit-metadata', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ element = document.createElement('mr-edit-metadata');
+ document.body.appendChild(element);
+
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+ sinon.stub(store, 'dispatch');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ store.dispatch.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditMetadata);
+ });
+
+ describe('updated sets initial values', () => {
+ it('updates owner', async () => {
+ element.ownerName = 'goose@bird.org';
+ await element.updateComplete;
+
+ assert.equal(element._values.owner, 'goose@bird.org');
+ });
+
+ it('updates cc', async () => {
+ element.cc = [
+ {displayName: 'initial-cc@bird.org', userId: '1234'},
+ ];
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.cc, ['initial-cc@bird.org']);
+ });
+
+ it('updates components', async () => {
+ element.components = [{path: 'Hello>World'}];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.components, ['Hello>World']);
+ });
+
+ it('updates labels', async () => {
+ element.labelNames = ['test-label'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.labels, ['test-label']);
+ });
+ });
+
+ describe('saves edit form', () => {
+ let saveStub;
+
+ beforeEach(() => {
+ saveStub = sinon.stub();
+ element.addEventListener('save', saveStub);
+ });
+
+ it('saves on form submit', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new Event('submit', {bubbles: true, cancelable: true}));
+
+ sinon.assert.calledOnce(saveStub);
+ });
+
+ it('saves when clicking the save button', async () => {
+ await element.updateComplete;
+
+ element.querySelector('.save-changes').click();
+
+ sinon.assert.calledOnce(saveStub);
+ });
+
+ it('does not save on random keydowns', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'a', ctrlKey: true}));
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'b', ctrlKey: false}));
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'c', metaKey: true}));
+
+ sinon.assert.notCalled(saveStub);
+ });
+
+ it('does not save on Enter without Ctrl', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: false}));
+
+ sinon.assert.notCalled(saveStub);
+ });
+
+ it('saves on Ctrl+Enter', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: true}));
+
+ sinon.assert.calledOnce(saveStub);
+ });
+
+ it('saves on Ctrl+Meta', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', metaKey: true}));
+
+ sinon.assert.calledOnce(saveStub);
+ });
+ });
+
+ it('disconnecting element reports form is not dirty', () => {
+ element.formName = 'test';
+
+ assert.isFalse(store.dispatch.calledOnce);
+
+ document.body.removeChild(element);
+
+ assert.isTrue(store.dispatch.calledOnce);
+ sinon.assert.calledWith(
+ store.dispatch,
+ {
+ type: 'REPORT_DIRTY_FORM',
+ name: 'test',
+ isDirty: false,
+ },
+ );
+
+ document.body.appendChild(element);
+ });
+
+ it('_processChanges fires change event', async () => {
+ await element.updateComplete;
+
+ const changeStub = sinon.stub();
+ element.addEventListener('change', changeStub);
+
+ element._processChanges();
+
+ sinon.assert.calledOnce(changeStub);
+ });
+
+ it('save button disabled when disabled is true', async () => {
+ // Check that save button is initially disabled.
+ await element.updateComplete;
+
+ const button = element.querySelector('.save-changes');
+
+ assert.isTrue(element.disabled);
+ assert.isTrue(button.disabled);
+
+ element.isDirty = true;
+
+ await element.updateComplete;
+
+ assert.isFalse(element.disabled);
+ assert.isFalse(button.disabled);
+ });
+
+ it('editing form sets isDirty to true or false', async () => {
+ await element.updateComplete;
+
+ assert.isFalse(element.isDirty);
+
+ // User makes some changes.
+ const comment = element.querySelector('#commentText');
+ comment.value = 'Value';
+ comment.dispatchEvent(new Event('keyup'));
+
+ assert.isTrue(element.isDirty);
+
+ // User undoes the changes.
+ comment.value = '';
+ comment.dispatchEvent(new Event('keyup'));
+
+ assert.isFalse(element.isDirty);
+ });
+
+ it('reseting form disables save button', async () => {
+ // Check that save button is initially disabled.
+ assert.isTrue(element.disabled);
+
+ // User makes some changes.
+ element.isDirty = true;
+
+ // Check that save button is not disabled.
+ assert.isFalse(element.disabled);
+
+ // Reset form.
+ await element.updateComplete;
+ await element.reset();
+
+ // Check that save button is still disabled.
+ assert.isTrue(element.disabled);
+ });
+
+ it('save button is enabled if request fails', async () => {
+ // Check that save button is initially disabled.
+ assert.isTrue(element.disabled);
+
+ // User makes some changes.
+ element.isDirty = true;
+
+ // Check that save button is not disabled.
+ assert.isFalse(element.disabled);
+
+ // User submits the change.
+ element.saving = true;
+
+ // Check that save button is disabled.
+ assert.isTrue(element.disabled);
+
+ // Request fails.
+ element.saving = false;
+ element.error = 'error';
+
+ // Check that save button is re-enabled.
+ assert.isFalse(element.disabled);
+ });
+
+ it('delta empty when no changes', async () => {
+ await element.updateComplete;
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('toggling checkbox toggles sendEmail', async () => {
+ element.sendEmail = false;
+
+ await element.updateComplete;
+ const checkbox = element.querySelector('#sendEmail');
+
+ await checkbox.updateComplete;
+
+ checkbox.click();
+ await element.updateComplete;
+
+ assert.equal(checkbox.checked, true);
+ assert.equal(element.sendEmail, true);
+
+ checkbox.click();
+ await element.updateComplete;
+
+ assert.equal(checkbox.checked, false);
+ assert.equal(element.sendEmail, false);
+
+ checkbox.click();
+ await element.updateComplete;
+
+ assert.equal(checkbox.checked, true);
+ assert.equal(element.sendEmail, true);
+ });
+
+ it('changing status produces delta change (lit-element)', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Old'},
+ {'status': 'Test'},
+ ];
+ element.status = 'New';
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ statusComponent.status = 'Old';
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ status: 'Old',
+ });
+ });
+
+ it('changing owner produces delta change (React)', async () => {
+ element.ownerName = 'initial-owner@bird.org';
+ await element.updateComplete;
+
+ const input = element.querySelector('#ownerInput');
+ enterInput(input, 'new-owner@bird.org');
+ await element.updateComplete;
+
+ const expected = {ownerRef: {displayName: 'new-owner@bird.org'}};
+ assert.deepEqual(element.delta, expected);
+ });
+
+ it('adding CC produces delta change (React)', async () => {
+ element.cc = [
+ {displayName: 'initial-cc@bird.org', userId: '1234'},
+ ];
+
+ await element.updateComplete;
+
+ const input = element.querySelector('#ccInput');
+ enterInput(input, 'another@bird.org');
+ await element.updateComplete;
+
+ const expected = {
+ ccRefsAdd: [{displayName: 'another@bird.org'}],
+ ccRefsRemove: [{displayName: 'initial-cc@bird.org'}],
+ };
+ assert.deepEqual(element.delta, expected);
+ });
+
+ it('invalid status throws', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Old'},
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ statusComponent.shadowRoot.querySelector('#mergedIntoInput').value = 'xx';
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ 'Invalid issue ref: xx. Expected [projectName:]issueId.');
+ });
+
+ it('cannot block an issue on itself', async () => {
+ element.projectName = 'proj';
+ element.issueRef = {projectName: 'proj', localId: 123};
+
+ await element.updateComplete;
+
+ for (const fieldName of ['blockedOn', 'blocking']) {
+ const input =
+ element.querySelector(`#${fieldName}Input`);
+ enterInput(input, '123');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: 123. Cannot merge or block an issue on itself.`);
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+
+ enterInput(input, 'proj:123');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: proj:123. ` +
+ 'Cannot merge or block an issue on itself.');
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+
+ enterInput(input, 'proj2:123');
+ await element.updateComplete;
+
+ assert.notDeepEqual(element.delta, {});
+ assert.equal(element.error, '');
+
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+ }
+ });
+
+ it('cannot merge an issue into itself', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'New';
+ element.projectName = 'proj';
+ element.issueRef = {projectName: 'proj', localId: 123};
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ const root = statusComponent.shadowRoot;
+ const statusInput = root.querySelector('#statusInput');
+ statusInput.value = 'Duplicate';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ root.querySelector('#mergedIntoInput').value = 'proj:123';
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: proj:123. Cannot merge or block an issue on itself.`);
+
+ root.querySelector('#mergedIntoInput').value = '123';
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: 123. Cannot merge or block an issue on itself.`);
+
+ root.querySelector('#mergedIntoInput').value = 'proj2:123';
+ assert.notDeepEqual(element.delta, {});
+ assert.equal(element.error, '');
+ });
+
+ it('cannot set invalid emails', async () => {
+ await element.updateComplete;
+
+ const ccInput = element.querySelector('#ccInput');
+ enterInput(ccInput, 'invalid!email');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid email address: invalid!email`);
+
+ const input = element.querySelector('#ownerInput');
+ enterInput(input, 'invalid!email2');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid email address: invalid!email2`);
+ });
+
+ it('can remove invalid values', async () => {
+ element.projectName = 'proj';
+ element.issueRef = {projectName: 'proj', localId: 123};
+
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+ element.mergedInto = element.issueRef;
+
+ element.blockedOn = [element.issueRef];
+ element.blocking = [element.issueRef];
+
+ await element.updateComplete;
+
+ const blockedOnInput = element.querySelector('#blockedOnInput');
+ const blockingInput = element.querySelector('#blockingInput');
+ const statusInput = element.querySelector('#statusInput');
+
+ await element.updateComplete;
+
+ const mergedIntoInput =
+ statusInput.shadowRoot.querySelector('#mergedIntoInput');
+
+ fireEvent.keyDown(blockedOnInput, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+ fireEvent.keyDown(blockingInput, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+ mergedIntoInput.value = 'proj:124';
+ await element.updateComplete;
+
+ assert.deepEqual(
+ element.delta,
+ {
+ blockedOnRefsRemove: [{projectName: 'proj', localId: 123}],
+ blockingRefsRemove: [{projectName: 'proj', localId: 123}],
+ mergedIntoRef: {projectName: 'proj', localId: 124},
+ });
+ assert.equal(element.error, '');
+ });
+
+ it('not changing status produces no delta', async () => {
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+
+ element.mergedInto = {
+ projectName: 'chromium',
+ localId: 1234,
+ };
+
+ element.projectName = 'chromium';
+
+ await element.updateComplete;
+ await element.updateComplete; // Merged input updates its value.
+
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('changing status to duplicate produces delta change', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'New';
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector(
+ '#statusInput');
+ const root = statusComponent.shadowRoot;
+ const statusInput = root.querySelector('#statusInput');
+ statusInput.value = 'Duplicate';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ root.querySelector('#mergedIntoInput').value = 'chromium:1234';
+ assert.deepEqual(element.delta, {
+ status: 'Duplicate',
+ mergedIntoRef: {
+ projectName: 'chromium',
+ localId: 1234,
+ },
+ });
+ });
+
+ it('changing summary produces delta change', async () => {
+ element.summary = 'Old summary';
+
+ await element.updateComplete;
+
+ element.querySelector(
+ '#summaryInput').value = 'newfangled fancy summary';
+ assert.deepEqual(element.delta, {
+ summary: 'newfangled fancy summary',
+ });
+ });
+
+ it('custom fields the user cannot edit should be hidden', async () => {
+ element.projectName = 'proj';
+ const fieldName = 'projects/proj/fieldDefs/1';
+ const restrictedFieldName = 'projects/proj/fieldDefs/2';
+ element._permissions = {
+ [fieldName]: {permissions: [FIELD_DEF_VALUE_EDIT]},
+ [restrictedFieldName]: {permissions: []}};
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'normalFd',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'cantEditFd',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ await element.updateComplete;
+ assert.isFalse(element.querySelector('#normalFdInput').hidden);
+ assert.isTrue(element.querySelector('#cantEditFdInput').hidden);
+ });
+
+ it('changing enum custom fields produces delta', async () => {
+ element.fieldValueMap = new Map([['fakefield', ['prev value']]]);
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'testField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'fakeField',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ await element.updateComplete;
+
+ const input1 = element.querySelector('#testFieldInput');
+ const input2 = element.querySelector('#fakeFieldInput');
+
+ input1.values = ['test value'];
+ input2.values = [];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ fieldValsAdd: [
+ {
+ fieldRef: {
+ fieldName: 'testField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'test value',
+ },
+ ],
+ fieldValsRemove: [
+ {
+ fieldRef: {
+ fieldName: 'fakeField',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ value: 'prev value',
+ },
+ ],
+ });
+ });
+
+ it('changing approvers produces delta', async () => {
+ element.isApproval = true;
+ element.hasApproverPrivileges = true;
+ element.approvers = [
+ {displayName: 'foo@example.com', userId: '1'},
+ {displayName: 'bar@example.com', userId: '2'},
+ {displayName: 'baz@example.com', userId: '3'},
+ ];
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ element.querySelector('#approversInput').values =
+ ['chicken@example.com', 'foo@example.com', 'dog@example.com'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ approverRefsAdd: [
+ {displayName: 'chicken@example.com'},
+ {displayName: 'dog@example.com'},
+ ],
+ approverRefsRemove: [
+ {displayName: 'bar@example.com'},
+ {displayName: 'baz@example.com'},
+ ],
+ });
+ });
+
+ it('changing blockedon produces delta change (React)', async () => {
+ element.blockedOn = [
+ {projectName: 'chromium', localId: '1234'},
+ {projectName: 'monorail', localId: '4567'},
+ ];
+ element.projectName = 'chromium';
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ const input = element.querySelector('#blockedOnInput');
+
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+
+ enterInput(input, 'v8:5678');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ blockedOnRefsAdd: [{
+ projectName: 'v8',
+ localId: 5678,
+ }],
+ blockedOnRefsRemove: [{
+ projectName: 'monorail',
+ localId: 4567,
+ }],
+ });
+ });
+
+ it('_optionsForField computes options', () => {
+ const optionsPerEnumField = new Map([
+ ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+ ]);
+ assert.deepEqual(
+ element._optionsForField(optionsPerEnumField, new Map(), 'enumField'), [
+ {
+ optionName: 'one',
+ },
+ {
+ optionName: 'two',
+ },
+ ]);
+ });
+
+ it('changing enum fields produces delta', async () => {
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ isMultivalued: true,
+ },
+ ];
+
+ element.optionsPerEnumField = new Map([
+ ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+ ]);
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ element.querySelector(
+ '#enumFieldInput').values = ['one', 'two'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ fieldValsAdd: [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'one',
+ },
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'two',
+ },
+ ],
+ });
+ });
+
+ it('changing multiple single valued enum fields', async () => {
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'enumField2',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ element.optionsPerEnumField = new Map([
+ ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+ ['enumfield2', [{optionName: 'three'}, {optionName: 'four'}]],
+ ]);
+
+ await element.updateComplete;
+
+ element.querySelector('#enumFieldInput').values = ['two'];
+ element.querySelector('#enumField2Input').values = ['three'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ fieldValsAdd: [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'two',
+ },
+ {
+ fieldRef: {
+ fieldName: 'enumField2',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ value: 'three',
+ },
+ ],
+ });
+ });
+
+ it('adding components produces delta', async () => {
+ await element.updateComplete;
+
+ element.isApproval = false;
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+ element.components = [];
+
+ await element.updateComplete;
+
+ element._values.components = ['Hello>World'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ compRefsAdd: [
+ {path: 'Hello>World'},
+ ],
+ });
+
+ element._values.components = ['Hello>World', 'Test', 'Multi'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ compRefsAdd: [
+ {path: 'Hello>World'},
+ {path: 'Test'},
+ {path: 'Multi'},
+ ],
+ });
+
+ element._values.components = [];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('removing components produces delta', async () => {
+ await element.updateComplete;
+
+ element.isApproval = false;
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+ element.components = [{path: 'Hello>World'}];
+
+ await element.updateComplete;
+
+ element._values.components = [];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ compRefsRemove: [
+ {path: 'Hello>World'},
+ ],
+ });
+ });
+
+ it('approver input appears when user has privileges', async () => {
+ assert.isNull(element.querySelector('#approversInput'));
+ element.isApproval = true;
+ element.hasApproverPrivileges = true;
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.querySelector('#approversInput'));
+ });
+
+ it('reset sets controlled values to default', async () => {
+ element.ownerName = 'burb@bird.com';
+ element.cc = [
+ {displayName: 'flamingo@bird.com', userId: '1234'},
+ {displayName: 'penguin@bird.com', userId: '5678'},
+ ];
+ element.components = [{path: 'Bird>Penguin'}];
+ element.labelNames = ['chickadee-chirp'];
+ element.blockedOn = [{localId: 1234, projectName: 'project'}];
+ element.blocking = [{localId: 5678, projectName: 'other-project'}];
+ element.projectName = 'project';
+
+ // Update cycle is needed because <mr-edit-metadata> initializes
+ // this.values in updated().
+ await element.updateComplete;
+
+ const initialValues = {
+ owner: 'burb@bird.com',
+ cc: ['flamingo@bird.com', 'penguin@bird.com'],
+ components: ['Bird>Penguin'],
+ labels: ['chickadee-chirp'],
+ blockedOn: ['1234'],
+ blocking: ['other-project:5678'],
+ };
+
+ assert.deepEqual(element._values, initialValues);
+
+ element._values = {
+ owner: 'newburb@hello.com',
+ cc: ['noburbs@wings.com'],
+ };
+ element.reset();
+
+ assert.deepEqual(element._values, initialValues);
+ })
+
+ it('reset empties form values', async () => {
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'testField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'fakeField',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ await element.updateComplete;
+
+ const uploader = element.querySelector('mr-upload');
+ uploader.files = [
+ {name: 'test.png'},
+ {name: 'rutabaga.png'},
+ ];
+
+ element.querySelector('#testFieldInput').values = 'testy test';
+ element.querySelector('#fakeFieldInput').values = 'hello world';
+
+ await element.reset();
+
+ assert.lengthOf(element.querySelector('#testFieldInput').value, 0);
+ assert.lengthOf(element.querySelector('#fakeFieldInput').value, 0);
+ assert.lengthOf(uploader.files, 0);
+ });
+
+ it('reset results in empty delta', async () => {
+ element.ownerName = 'goose@bird.org';
+ await element.updateComplete;
+
+ element._values.owner = 'penguin@bird.org';
+ const expected = {ownerRef: {displayName: 'penguin@bird.org'}};
+ assert.deepEqual(element.delta, expected);
+
+ await element.reset();
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('edit issue permissions', async () => {
+ const allFields = ['summary', 'status', 'owner', 'cc'];
+ const testCases = [
+ {permissions: [], nonNull: []},
+ {permissions: [ISSUE_EDIT_PERMISSION], nonNull: allFields},
+ {permissions: [ISSUE_EDIT_SUMMARY_PERMISSION], nonNull: ['summary']},
+ {permissions: [ISSUE_EDIT_STATUS_PERMISSION], nonNull: ['status']},
+ {permissions: [ISSUE_EDIT_OWNER_PERMISSION], nonNull: ['owner']},
+ {permissions: [ISSUE_EDIT_CC_PERMISSION], nonNull: ['cc']},
+ ];
+ element.statuses = [{'status': 'Foo'}];
+
+ for (const testCase of testCases) {
+ element.issuePermissions = testCase.permissions;
+ await element.updateComplete;
+
+ allFields.forEach((fieldName) => {
+ const field = element.querySelector(`#${fieldName}Input`);
+ if (testCase.nonNull.includes(fieldName)) {
+ assert.isNotNull(field);
+ } else {
+ assert.isNull(field);
+ }
+ });
+ }
+ });
+
+ it('duplicate issue is rendered correctly', async () => {
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+ element.projectName = 'chromium';
+ element.mergedInto = {
+ projectName: 'chromium',
+ localId: 1234,
+ };
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ const root = statusComponent.shadowRoot;
+ assert.equal(
+ root.querySelector('#mergedIntoInput').value, '1234');
+ });
+
+ it('duplicate issue on different project is rendered correctly', async () => {
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+ element.projectName = 'chromium';
+ element.mergedInto = {
+ projectName: 'monorail',
+ localId: 1234,
+ };
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ const root = statusComponent.shadowRoot;
+ assert.equal(
+ root.querySelector('#mergedIntoInput').value, 'monorail:1234');
+ });
+
+ it('filter out deleted users', async () => {
+ element.cc = [
+ {displayName: 'test@example.com', userId: '1234'},
+ {displayName: 'a_deleted_user'},
+ {displayName: 'someone@example.com', userId: '5678'},
+ ];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.cc, [
+ 'test@example.com',
+ 'someone@example.com',
+ ]);
+ });
+
+ it('renders valid markdown description with preview', async () => {
+ await element.updateComplete;
+
+ element.prefs = new Map([['render_markdown', true]]);
+ element.projectName = 'monkeyrail';
+ sinon.stub(element, 'getCommentContent').returns('# h1');
+
+ await element.updateComplete;
+
+ assert.isTrue(element._renderMarkdown);
+
+ const previewMarkdown = element.querySelector('.markdown-preview');
+ assert.isNotNull(previewMarkdown);
+
+ const headerText = previewMarkdown.querySelector('h1').textContent;
+ assert.equal(headerText, 'h1');
+ });
+
+ it('does not show preview when markdown is disabled', async () => {
+ element.prefs = new Map([['render_markdown', false]]);
+ element.projectName = 'monkeyrail';
+ sinon.stub(element, 'getCommentContent').returns('# h1');
+
+ await element.updateComplete;
+
+ const previewMarkdown = element.querySelector('.markdown-preview');
+ assert.isNull(previewMarkdown);
+ });
+
+ it('does not show preview when no input', async () => {
+ element.prefs = new Map([['render_markdown', true]]);
+ element.projectName = 'monkeyrail';
+ sinon.stub(element, 'getCommentContent').returns('');
+
+ await element.updateComplete;
+
+ const previewMarkdown = element.querySelector('.markdown-preview');
+ assert.isNull(previewMarkdown);
+ });
+});
+
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js
new file mode 100644
index 0000000..ba68c39
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js
@@ -0,0 +1,58 @@
+// 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} from 'lit-element';
+
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import {fieldTypes, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {displayNameToUserRef} from 'shared/convertersV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-field-values>`
+ *
+ * Takes in a list of field values and a single fieldDef and displays them
+ * according to their type.
+ *
+ */
+export class MrFieldValues extends LitElement {
+ /** @override */
+ static get styles() {
+ return SHARED_STYLES;
+ }
+
+ /** @override */
+ render() {
+ if (!this.values || !this.values.length) {
+ return html`${EMPTY_FIELD_VALUE}`;
+ }
+ switch (this.type) {
+ case fieldTypes.URL_TYPE:
+ return html`${this.values.map((value) => html`
+ <a href=${value} target="_blank" rel="nofollow">${value}</a>
+ `)}`;
+ case fieldTypes.USER_TYPE:
+ return html`${this.values.map((value) => html`
+ <mr-user-link .userRef=${displayNameToUserRef(value)}></mr-user-link>
+ `)}`;
+ default:
+ return html`${this.values.map((value, i) => html`
+ <a href="/p/${this.projectName}/issues/list?q=${this.name}="${value}"">
+ ${value}</a>${this.values.length - 1 > i ? ', ' : ''}
+ `)}`;
+ }
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ name: {type: String},
+ type: {type: Object},
+ projectName: {type: String},
+ values: {type: Array},
+ };
+ }
+}
+
+customElements.define('mr-field-values', MrFieldValues);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js
new file mode 100644
index 0000000..e334841
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js
@@ -0,0 +1,86 @@
+// 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 {MrFieldValues} from './mr-field-values.js';
+
+import {fieldTypes} from 'shared/issue-fields.js';
+
+
+let element;
+
+describe('mr-field-values', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-field-values');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrFieldValues);
+ });
+
+ it('renders empty if no values', async () => {
+ element.values = [];
+
+ await element.updateComplete;
+
+ assert.equal('----', element.shadowRoot.textContent.trim());
+ });
+
+ it('renders user links when type is user', async () => {
+ element.type = fieldTypes.USER_TYPE;
+ element.values = ['test@example.com', 'hello@world.com'];
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-user-link');
+
+ await links.updateComplete;
+
+ assert.equal(2, links.length);
+ assert.include(links[0].shadowRoot.textContent, 'test@example.com');
+ assert.include(links[1].shadowRoot.textContent, 'hello@world.com');
+ });
+
+ it('renders URLs when type is url', async () => {
+ element.type = fieldTypes.URL_TYPE;
+ element.values = ['http://hello.world', 'go/link'];
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('a');
+
+ assert.equal(2, links.length);
+ assert.include(links[0].textContent, 'http://hello.world');
+ assert.include(links[0].href, 'http://hello.world');
+ assert.include(links[1].textContent, 'go/link');
+ assert.include(links[1].href, 'go/link');
+ });
+
+ it('renders generic field when field is string', async () => {
+ element.type = fieldTypes.STR_TYPE;
+ element.values = ['blah', 'random value', 'nothing here'];
+ element.name = 'fieldName';
+ element.projectName = 'project';
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('a');
+
+ assert.equal(3, links.length);
+ assert.include(links[0].textContent, 'blah');
+ assert.include(links[0].href,
+ '/p/project/issues/list?q=fieldName=%22blah%22');
+ assert.include(links[1].textContent, 'random value');
+ assert.include(links[1].href,
+ '/p/project/issues/list?q=fieldName=%22random%20value%22');
+ assert.include(links[2].textContent, 'nothing here');
+ assert.include(links[2].href,
+ '/p/project/issues/list?q=fieldName=%22nothing%20here%22');
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js
new file mode 100644
index 0000000..60d570c
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js
@@ -0,0 +1,352 @@
+// 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 projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/links/mr-hotlist-link/mr-hotlist-link.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {pluralize} from 'shared/helpers.js';
+import './mr-metadata.js';
+
+
+/**
+ * `<mr-issue-metadata>`
+ *
+ * The metadata view for a single issue. Contains information such as the owner.
+ *
+ */
+export class MrIssueMetadata extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ box-sizing: border-box;
+ padding: 0.25em 8px;
+ max-width: 100%;
+ display: block;
+ }
+ h3 {
+ display: block;
+ font-size: var(--chops-main-font-size);
+ margin: 0;
+ line-height: 160%;
+ width: 40%;
+ height: 100%;
+ overflow: ellipsis;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ a.label {
+ color: hsl(120, 100%, 25%);
+ text-decoration: none;
+ }
+ a.label[data-derived] {
+ font-style: italic;
+ }
+ button.linkify {
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ padding: 0.25em 0;
+ }
+ button.linkify i.material-icons {
+ margin-right: 4px;
+ font-size: var(--chops-icon-font-size);
+ }
+ mr-hotlist-link {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: block;
+ width: 100%;
+ }
+ .bottom-section-cell, .labels-container {
+ padding: 0.5em 4px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ .bottom-section-cell {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: flex-start;
+ }
+ .bottom-section-content {
+ max-width: 60%;
+ }
+ .star-line {
+ width: 100%;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ mr-issue-star {
+ margin-right: 4px;
+ padding-bottom: 2px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ const hotlistsByRole = this._hotlistsByRole;
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <div class="star-line">
+ <mr-issue-star
+ .issueRef=${this.issueRef}
+ ></mr-issue-star>
+ Starred by ${this.issue.starCount || 0} ${pluralize(this.issue.starCount, 'user')}
+ </div>
+ <mr-metadata
+ aria-label="Issue Metadata"
+ .owner=${this.issue.ownerRef}
+ .cc=${this.issue.ccRefs}
+ .issueStatus=${this.issue.statusRef}
+ .components=${this._components}
+ .fieldDefs=${this._fieldDefs}
+ .mergedInto=${this.mergedInto}
+ .modifiedTimestamp=${this.issue.modifiedTimestamp}
+ ></mr-metadata>
+
+ <div class="labels-container">
+ ${this.issue.labelRefs && this.issue.labelRefs.map((label) => html`
+ <a
+ title="${_labelTitle(this.labelDefMap, label)}"
+ href="/p/${this.issueRef.projectName}/issues/list?q=label:${label.label}"
+ class="label"
+ ?data-derived=${label.isDerived}
+ >${label.label}</a>
+ <br>
+ `)}
+ </div>
+
+ ${this.sortedBlockedOn.length ? html`
+ <div class="bottom-section-cell">
+ <h3>BlockedOn:</h3>
+ <div class="bottom-section-content">
+ ${this.sortedBlockedOn.map((issue) => html`
+ <mr-issue-link
+ .projectName=${this.issueRef.projectName}
+ .issue=${issue}
+ >
+ </mr-issue-link>
+ <br />
+ `)}
+ <button
+ class="linkify"
+ @click=${this.openViewBlockedOn}
+ >
+ <i class="material-icons" role="presentation">list</i>
+ View details
+ </button>
+ </div>
+ </div>
+ `: ''}
+
+ ${this.blocking.length ? html`
+ <div class="bottom-section-cell">
+ <h3>Blocking:</h3>
+ <div class="bottom-section-content">
+ ${this.blocking.map((issue) => html`
+ <mr-issue-link
+ .projectName=${this.issueRef.projectName}
+ .issue=${issue}
+ >
+ </mr-issue-link>
+ <br />
+ `)}
+ </div>
+ </div>
+ `: ''}
+
+ ${this._userId ? html`
+ <div class="bottom-section-cell">
+ <h3>Your Hotlists:</h3>
+ <div class="bottom-section-content" id="user-hotlists">
+ ${this._renderHotlists(hotlistsByRole.user)}
+ <button
+ class="linkify"
+ @click=${this.openUpdateHotlists}
+ >
+ <i class="material-icons" role="presentation">create</i> Update your hotlists
+ </button>
+ </div>
+ </div>
+ `: ''}
+
+ ${hotlistsByRole.participants.length ? html`
+ <div class="bottom-section-cell">
+ <h3>Participant's Hotlists:</h3>
+ <div class="bottom-section-content">
+ ${this._renderHotlists(hotlistsByRole.participants)}
+ </div>
+ </div>
+ ` : ''}
+
+ ${hotlistsByRole.others.length ? html`
+ <div class="bottom-section-cell">
+ <h3>Other Hotlists:</h3>
+ <div class="bottom-section-content">
+ ${this._renderHotlists(hotlistsByRole.others)}
+ </div>
+ </div>
+ ` : ''}
+ `;
+ }
+
+ /**
+ * Helper to render hotlists.
+ * @param {Array<Hotlist>} hotlists
+ * @return {Array<TemplateResult>}
+ * @private
+ */
+ _renderHotlists(hotlists) {
+ return hotlists.map((hotlist) => html`
+ <mr-hotlist-link .hotlist=${hotlist}></mr-hotlist-link>
+ `);
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issue: {type: Object},
+ issueRef: {type: Object},
+ projectConfig: String,
+ user: {type: Object},
+ issueHotlists: {type: Array},
+ blocking: {type: Array},
+ sortedBlockedOn: {type: Array},
+ relatedIssues: {type: Object},
+ labelDefMap: {type: Object},
+ _components: {type: Array},
+ _fieldDefs: {type: Array},
+ _type: {type: String},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.user = userV0.currentUser(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.blocking = issueV0.blockingIssues(state);
+ this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
+ this.mergedInto = issueV0.mergedInto(state);
+ this.relatedIssues = issueV0.relatedIssues(state);
+ this.issueHotlists = issueV0.hotlists(state);
+ this.labelDefMap = projectV0.labelDefMap(state);
+ this._components = issueV0.components(state);
+ this._fieldDefs = issueV0.fieldDefs(state);
+ this._type = issueV0.type(state);
+ }
+
+ /**
+ * @return {string|number} The current user's userId.
+ * @private
+ */
+ get _userId() {
+ return this.user && this.user.userId;
+ }
+
+ /**
+ * @return {Object<string, Array<Hotlist>>}
+ * @private
+ */
+ get _hotlistsByRole() {
+ const issueHotlists = this.issueHotlists;
+ const owner = this.issue && this.issue.ownerRef;
+ const cc = this.issue && this.issue.ccRefs;
+
+ const hotlists = {
+ user: [],
+ participants: [],
+ others: [],
+ };
+ (issueHotlists || []).forEach((hotlist) => {
+ if (hotlist.ownerRef.userId === this._userId) {
+ hotlists.user.push(hotlist);
+ } else if (_userIsParticipant(hotlist.ownerRef, owner, cc)) {
+ hotlists.participants.push(hotlist);
+ } else {
+ hotlists.others.push(hotlist);
+ }
+ });
+ return hotlists;
+ }
+
+ /**
+ * Opens dialog for updating ths issue's hotlists.
+ * @fires CustomEvent#open-dialog
+ */
+ openUpdateHotlists() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'update-issue-hotlists',
+ },
+ }));
+ }
+
+ /**
+ * Opens dialog with detailed view of blocked on issues.
+ * @fires CustomEvent#open-dialog
+ */
+ openViewBlockedOn() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'reorder-related-issues',
+ },
+ }));
+ }
+}
+
+/**
+ * @param {UserRef} user
+ * @param {UserRef} owner
+ * @param {Array<UserRef>} cc
+ * @return {boolean} Whether a given user is a participant of
+ * a given hotlist attached to an issue. Used to sort hotlists into
+ * "My hotlists" and "Other hotlists".
+ * @private
+ */
+function _userIsParticipant(user, owner, cc) {
+ if (owner && owner.userId === user.userId) {
+ return true;
+ }
+ return cc && cc.some((ccUser) => ccUser && ccUser.userId === user.userId);
+}
+
+/**
+ * @param {Map.<string, LabelDef>} labelDefMap
+ * @param {LabelDef} label
+ * @return {string} Tooltip shown to the user when hovering over a
+ * given label.
+ * @private
+ */
+function _labelTitle(labelDefMap, label) {
+ if (!label) return '';
+ let docstring = '';
+ const key = label.label.toLowerCase();
+ if (labelDefMap && labelDefMap.has(key)) {
+ docstring = labelDefMap.get(key).docstring;
+ }
+ return (label.isDerived ? 'Derived: ' : '') + label.label +
+ (docstring ? ` = ${docstring}` : '');
+}
+
+customElements.define('mr-issue-metadata', MrIssueMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js
new file mode 100644
index 0000000..c328057
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js
@@ -0,0 +1,60 @@
+// 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 {MrIssueMetadata} from './mr-issue-metadata.js';
+
+let element;
+
+describe('mr-issue-metadata', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-metadata');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueMetadata);
+ });
+
+ it('labels render', async () => {
+ element.issue = {
+ labelRefs: [
+ {label: 'test'},
+ {label: 'hello-world', isDerived: true},
+ ],
+ };
+
+ element.labelDefMap = new Map([
+ ['test', {label: 'test', docstring: 'this is a docstring'}],
+ ]);
+
+ await element.updateComplete;
+
+ const labels = element.shadowRoot.querySelectorAll('.label');
+
+ assert.equal(labels.length, 2);
+ assert.equal(labels[0].textContent.trim(), 'test');
+ assert.equal(labels[0].getAttribute('title'), 'test = this is a docstring');
+ assert.isUndefined(labels[0].dataset.derived);
+
+ assert.equal(labels[1].textContent.trim(), 'hello-world');
+ assert.equal(labels[1].getAttribute('title'), 'Derived: hello-world');
+ assert.isDefined(labels[1].dataset.derived);
+ });
+
+ it('update hotlist button is shown to users', async () => {
+ element.user = {userId: 1234};
+ await element.updateComplete;
+ assert.isNotNull(element.shadowRoot.querySelector('#user-hotlists'));
+ });
+
+ it('update hotlist button is not shown to anon', async () => {
+ await element.updateComplete;
+ assert.isNull(element.shadowRoot.querySelector('#user-hotlists'));
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js
new file mode 100644
index 0000000..0ce172d
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js
@@ -0,0 +1,357 @@
+// 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 'elements/chops/chops-timestamp/chops-timestamp.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/mr-issue-slo/mr-issue-slo.js';
+
+import * as issueV0 from 'reducers/issueV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as userV0 from 'reducers/userV0.js';
+import './mr-field-values.js';
+import {isExperimentEnabled, SLO_EXPERIMENT} from 'shared/experiments.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {HARDCODED_FIELD_GROUPS, valuesForField, fieldDefsWithGroup,
+ fieldDefsWithoutGroup} from 'shared/metadata-helpers.js';
+import 'shared/typedef.js';
+import {AVAILABLE_CUES, cueNames, specToCueName,
+ cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+
+/**
+ * `<mr-metadata>`
+ *
+ * Generalized metadata components, used for either approvals or issues.
+ *
+ */
+export class MrMetadata extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: table;
+ table-layout: fixed;
+ width: 100%;
+ }
+ td, th {
+ padding: 0.5em 4px;
+ vertical-align: top;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ td {
+ width: 60%;
+ }
+ td.allow-overflow {
+ overflow: visible;
+ }
+ th {
+ text-align: left;
+ width: 40%;
+ }
+ .group-separator {
+ border-top: var(--chops-normal-border);
+ }
+ .group-title {
+ font-weight: normal;
+ font-style: oblique;
+ border-bottom: var(--chops-normal-border);
+ text-align: center;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ ${this._renderBuiltInFields()}
+ ${this._renderCustomFieldGroups()}
+ `;
+ }
+
+ /**
+ * Helper for handling the rendering of built in fields.
+ * @return {Array<TemplateResult>}
+ */
+ _renderBuiltInFields() {
+ return this.builtInFieldSpec.map((fieldName) => {
+ const fieldKey = fieldName.toLowerCase();
+
+ // Adding classes to table rows based on field names makes selecting
+ // rows with specific values easier, for example in tests.
+ let className = `row-${fieldKey}`;
+
+ const cueName = specToCueName(fieldKey);
+ if (cueName) {
+ className = `cue-${cueName}`;
+
+ if (!AVAILABLE_CUES.has(cueName)) return '';
+
+ return html`
+ <tr class=${className}>
+ <td colspan="2">
+ <mr-cue cuePrefName=${cueName}></mr-cue>
+ </td>
+ </tr>
+ `;
+ }
+
+ const isApprovalStatus = fieldKey === 'approvalstatus';
+ const isMergedInto = fieldKey === 'mergedinto';
+
+ const fieldValueTemplate = this._renderBuiltInFieldValue(fieldName);
+
+ if (!fieldValueTemplate) return '';
+
+ // Allow overflow to enable the FedRef popup to expand.
+ // TODO(jeffcarp): Look into a more elegant solution.
+ return html`
+ <tr class=${className}>
+ <th>${isApprovalStatus ? 'Status' : fieldName}:</th>
+ <td class=${isMergedInto ? 'allow-overflow' : ''}>
+ ${fieldValueTemplate}
+ </td>
+ </tr>
+ `;
+ });
+ }
+
+ /**
+ * A helper to display a single built-in field.
+ *
+ * @param {string} fieldName The name of the built in field to render.
+ * @return {TemplateResult|undefined} lit-html template for displaying the
+ * value of the built in field. If undefined, the rendering code assumes
+ * that the field should be hidden if empty.
+ */
+ _renderBuiltInFieldValue(fieldName) {
+ // TODO(zhangtiff): Merge with code in shared/issue-fields.js for further
+ // de-duplication.
+ switch (fieldName.toLowerCase()) {
+ case 'approvalstatus':
+ return this.approvalStatus || EMPTY_FIELD_VALUE;
+ case 'approvers':
+ return this.approvers && this.approvers.length ?
+ this.approvers.map((approver) => html`
+ <mr-user-link
+ .userRef=${approver}
+ showAvailabilityIcon
+ ></mr-user-link>
+ <br />
+ `) : EMPTY_FIELD_VALUE;
+ case 'setter':
+ return this.setter ? html`
+ <mr-user-link
+ .userRef=${this.setter}
+ showAvailabilityIcon
+ ></mr-user-link>
+ ` : undefined; // Hide the field when empty.
+ case 'owner':
+ return this.owner ? html`
+ <mr-user-link
+ .userRef=${this.owner}
+ showAvailabilityIcon
+ showAvailabilityText
+ ></mr-user-link>
+ ` : EMPTY_FIELD_VALUE;
+ case 'cc':
+ return this.cc && this.cc.length ?
+ this.cc.map((cc) => html`
+ <mr-user-link
+ .userRef=${cc}
+ showAvailabilityIcon
+ ></mr-user-link>
+ <br />
+ `) : EMPTY_FIELD_VALUE;
+ case 'status':
+ return this.issueStatus ? html`
+ ${this.issueStatus.status} <em>${
+ this.issueStatus.meansOpen ? '(Open)' : '(Closed)'}
+ </em>` : EMPTY_FIELD_VALUE;
+ case 'mergedinto':
+ // TODO(zhangtiff): This should use the project config to determine if a
+ // field allows merging rather than used a hard-coded value.
+ return this.issueStatus && this.issueStatus.status === 'Duplicate' ?
+ html`
+ <mr-issue-link
+ .projectName=${this.issueRef.projectName}
+ .issue=${this.mergedInto}
+ ></mr-issue-link>
+ `: undefined; // Hide the field when empty.
+ case 'components':
+ return (this.components && this.components.length) ?
+ this.components.map((comp) => html`
+ <a
+ href="/p/${this.issueRef.projectName
+ }/issues/list?q=component:${comp.path}"
+ title="${comp.path}${comp.docstring ?
+ ' = ' + comp.docstring : ''}"
+ >
+ ${comp.path}</a><br />
+ `) : EMPTY_FIELD_VALUE;
+ case 'modified':
+ return this.modifiedTimestamp ? html`
+ <chops-timestamp
+ .timestamp=${this.modifiedTimestamp}
+ short
+ ></chops-timestamp>
+ ` : EMPTY_FIELD_VALUE;
+ case 'slo':
+ if (isExperimentEnabled(
+ SLO_EXPERIMENT, this.currentUser, this.queryParams)) {
+ return html`<mr-issue-slo .issue=${this.issue}></mr-issue-slo>`;
+ } else {
+ return;
+ }
+ }
+
+ // Non-existent field.
+ return;
+ }
+
+ /**
+ * Helper for handling the rendering of custom fields defined in a project
+ * config.
+ * @return {TemplateResult} lit-html template.
+ */
+ _renderCustomFieldGroups() {
+ const grouped = fieldDefsWithGroup(this.fieldDefs,
+ this.fieldGroups, this.issueType);
+ const ungrouped = fieldDefsWithoutGroup(this.fieldDefs,
+ this.fieldGroups, this.issueType);
+ return html`
+ ${grouped.map((group) => html`
+ <tr>
+ <th class="group-title" colspan="2">
+ ${group.groupName}
+ </th>
+ </tr>
+ ${this._renderCustomFields(group.fieldDefs)}
+ <tr>
+ <th class="group-separator" colspan="2"></th>
+ </tr>
+ `)}
+
+ ${this._renderCustomFields(ungrouped)}
+ `;
+ }
+
+ /**
+ * Helper for handling the rendering of built in fields.
+ *
+ * @param {Array<FieldDef>} fieldDefs Arrays of configurations Objects
+ * for fields to render.
+ * @return {Array<TemplateResult>} Array of lit-html templates to render, each
+ * representing a single table row for a custom field.
+ */
+ _renderCustomFields(fieldDefs) {
+ if (!fieldDefs || !fieldDefs.length) return [];
+ return fieldDefs.map((field) => {
+ const fieldValues = valuesForField(
+ this.fieldValueMap, field.fieldRef.fieldName) || [];
+ return html`
+ <tr ?hidden=${field.isNiche && !fieldValues.length}>
+ <th title=${field.docstring}>${field.fieldRef.fieldName}:</th>
+ <td>
+ <mr-field-values
+ .name=${field.fieldRef.fieldName}
+ .type=${field.fieldRef.type}
+ .values=${fieldValues}
+ .projectName=${this.issueRef.projectName}
+ ></mr-field-values>
+ </td>
+ </tr>
+ `;
+ });
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * An Array of Strings to specify which built in fields to display.
+ */
+ builtInFieldSpec: {type: Array},
+ approvalStatus: {type: Array},
+ approvers: {type: Array},
+ setter: {type: Object},
+ cc: {type: Array},
+ components: {type: Array},
+ fieldDefs: {type: Array},
+ fieldGroups: {type: Array},
+ issue: {type: Object},
+ issueStatus: {type: String},
+ issueType: {type: String},
+ mergedInto: {type: Object},
+ modifiedTimestamp: {type: Number},
+ owner: {type: Object},
+ isApproval: {type: Boolean},
+ issueRef: {type: Object},
+ fieldValueMap: {type: Object},
+ currentUser: {type: Object},
+ queryParams: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.isApproval = false;
+ this.fieldGroups = HARDCODED_FIELD_GROUPS;
+ this.issueRef = {};
+
+ // Default built in fields used by issue metadata.
+ this.builtInFieldSpec = [
+ 'Owner', 'CC', cueNameToSpec(cueNames.AVAILABILITY_MSGS),
+ 'Status', 'MergedInto', 'Components', 'Modified', 'SLO',
+ ];
+ this.fieldValueMap = new Map();
+
+ this.approvalStatus = undefined;
+ this.approvers = undefined;
+ this.setter = undefined;
+ this.cc = undefined;
+ this.components = undefined;
+ this.fieldDefs = undefined;
+ this.issue = undefined;
+ this.issueStatus = undefined;
+ this.issueType = undefined;
+ this.mergedInto = undefined;
+ this.owner = undefined;
+ this.modifiedTimestamp = undefined;
+ this.currentUser = undefined;
+ this.queryParams = {};
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ // This is set for accessibility. Do not override.
+ this.setAttribute('role', 'table');
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.fieldValueMap = issueV0.fieldValueMap(state);
+ this.issue = issueV0.viewedIssue(state);
+ this.issueType = issueV0.type(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.relatedIssues = issueV0.relatedIssues(state);
+ this.currentUser = userV0.currentUser(state);
+ this.queryParams = sitewide.queryParams(state);
+ }
+}
+
+customElements.define('mr-metadata', MrMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js
new file mode 100644
index 0000000..d9dcd25
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js
@@ -0,0 +1,345 @@
+// 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 {MrMetadata} from './mr-metadata.js';
+
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+
+let element;
+
+describe('mr-metadata', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-metadata');
+ document.body.appendChild(element);
+
+ element.issueRef = {projectName: 'proj'};
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrMetadata);
+ });
+
+ it('has table role set', () => {
+ assert.equal(element.getAttribute('role'), 'table');
+ });
+
+ describe('default issue fields', () => {
+ it('renders empty Owner', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-owner');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Owner:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated Owner', async () => {
+ element.owner = {displayName: 'test@example.com'};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-owner');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'Owner:');
+ assert.include(dataElement.shadowRoot.textContent.trim(),
+ 'test@example.com');
+ });
+
+ it('renders empty CC', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-cc');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'CC:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders multiple CCed users', async () => {
+ element.cc = [
+ {displayName: 'test@example.com'},
+ {displayName: 'hello@example.com'},
+ ];
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-cc');
+ const labelElement = tr.querySelector('th');
+ const dataElements = tr.querySelectorAll('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'CC:');
+ assert.include(dataElements[0].shadowRoot.textContent.trim(),
+ 'test@example.com');
+ assert.include(dataElements[1].shadowRoot.textContent.trim(),
+ 'hello@example.com');
+ });
+
+ it('renders empty Status', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-status');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated Status', async () => {
+ element.issueStatus = {status: 'Fixed', meansOpen: false};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-status');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), 'Fixed (Closed)');
+ });
+
+ it('hides empty MergedInto', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+ assert.isNull(tr);
+ });
+
+ it('hides MergedInto when Status is not Duplicate', async () => {
+ element.issueStatus = {status: 'test'};
+ element.mergedInto = {projectName: 'chromium', localId: 22};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+ assert.isNull(tr);
+ });
+
+ it('shows MergedInto when Status is Duplicate', async () => {
+ element.issueStatus = {status: 'Duplicate'};
+ element.mergedInto = {projectName: 'chromium', localId: 22};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-issue-link');
+
+ assert.equal(labelElement.textContent, 'MergedInto:');
+ assert.equal(dataElement.shadowRoot.textContent.trim(),
+ 'Issue chromium:22');
+ });
+
+ it('renders empty Components', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-components');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Components:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders multiple Components', async () => {
+ element.components = [
+ {path: 'Test', docstring: 'i got docs'},
+ {path: 'Test>Nothing'},
+ ];
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-components');
+ const labelElement = tr.querySelector('th');
+ const dataElements = tr.querySelectorAll('td > a');
+
+ assert.equal(labelElement.textContent, 'Components:');
+
+ assert.equal(dataElements[0].textContent.trim(), 'Test');
+ assert.equal(dataElements[0].title, 'Test = i got docs');
+
+ assert.equal(dataElements[1].textContent.trim(), 'Test>Nothing');
+ assert.equal(dataElements[1].title, 'Test>Nothing');
+ });
+
+ it('renders empty Modified', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-modified');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Modified:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated Modified', async () => {
+ element.modifiedTimestamp = 1234;
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-modified');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('chops-timestamp');
+
+ assert.equal(labelElement.textContent, 'Modified:');
+ assert.equal(dataElement.timestamp, 1234);
+ });
+
+ it('does not render SLO if user not in experiment', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-slo');
+ assert.isNull(tr);
+ });
+
+ it('renders SLO if user in experiment', async () => {
+ element.currentUser = {displayName: 'jessan@google.com'};
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-slo');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-issue-slo');
+
+ assert.equal(labelElement.textContent, 'SLO:');
+ assert.equal(dataElement.shadowRoot.textContent.trim(), 'N/A');
+ });
+ });
+
+ describe('approval fields', () => {
+ beforeEach(() => {
+ element.builtInFieldSpec = ['ApprovalStatus', 'Approvers', 'Setter',
+ 'cue.availability_msgs'];
+ });
+
+ it('renders empty ApprovalStatus', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvalstatus');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated ApprovalStatus', async () => {
+ element.approvalStatus = 'Approved';
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvalstatus');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), 'Approved');
+ });
+
+ it('renders empty Approvers', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvers');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Approvers:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders multiple Approvers', async () => {
+ element.approvers = [
+ {displayName: 'test@example.com'},
+ {displayName: 'hello@example.com'},
+ ];
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvers');
+ const labelElement = tr.querySelector('th');
+ const dataElements = tr.querySelectorAll('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'Approvers:');
+ assert.include(dataElements[0].shadowRoot.textContent.trim(),
+ 'test@example.com');
+ assert.include(dataElements[1].shadowRoot.textContent.trim(),
+ 'hello@example.com');
+ });
+
+ it('hides empty Setter', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-setter');
+
+ assert.isNull(tr);
+ });
+
+ it('renders populated Setter', async () => {
+ element.setter = {displayName: 'test@example.com'};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-setter');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'Setter:');
+ assert.include(dataElement.shadowRoot.textContent.trim(),
+ 'test@example.com');
+ });
+
+ it('renders cue.availability_msgs', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector(
+ 'tr.cue-availability_msgs');
+ const cueElement = tr.querySelector('mr-cue');
+
+ assert.isDefined(cueElement);
+ });
+ });
+
+ describe('custom config', () => {
+ beforeEach(() => {
+ element.builtInFieldSpec = ['owner', 'fakefield'];
+ });
+
+ it('owner still renders when lowercase', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-owner');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'owner:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('fakefield does not render', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-fakefield');
+
+ assert.isNull(tr);
+ });
+
+ it('cue.availability_msgs does not render when not configured', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.cue-availability_msgs');
+
+ assert.isNull(tr);
+ });
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js
new file mode 100644
index 0000000..2d74c10
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js
@@ -0,0 +1,452 @@
+// 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} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/chops/chops-collapse/chops-collapse.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 ui from 'reducers/ui.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import 'elements/framework/mr-comment-content/mr-description.js';
+import '../mr-comment-list/mr-comment-list.js';
+import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
+import 'elements/issue-detail/metadata/mr-metadata/mr-metadata.js';
+import {APPROVER_RESTRICTED_STATUSES, STATUS_ENUM_TO_TEXT, TEXT_TO_STATUS_ENUM,
+ STATUS_CLASS_MAP, CLASS_ICON_MAP, APPROVAL_STATUSES,
+} from 'shared/consts/approval.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+import {cueNames, cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js';
+
+
+/**
+ * @type {Array<string>} The list of built in metadata fields to show on
+ * issue approvals.
+ */
+const APPROVAL_METADATA_FIELDS = ['ApprovalStatus', 'Approvers', 'Setter',
+ cueNameToSpec(cueNames.AVAILABILITY_MSGS)];
+
+/**
+ * `<mr-approval-card>`
+ *
+ * This element shows a card for a single approval.
+ *
+ */
+export class MrApprovalCard extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <style>
+ mr-approval-card {
+ width: 100%;
+ background-color: var(--chops-white);
+ font-size: var(--chops-main-font-size);
+ border-bottom: var(--chops-normal-border);
+ box-sizing: border-box;
+ display: block;
+ border-left: 4px solid var(--approval-bg-color);
+
+ /* Default styles are for the NotSet/NeedsReview case. */
+ --approval-bg-color: var(--chops-purple-50);
+ --approval-accent-color: var(--chops-purple-700);
+ }
+ mr-approval-card.status-na {
+ --approval-bg-color: hsl(227, 20%, 92%);
+ --approval-accent-color: hsl(227, 80%, 40%);
+ }
+ mr-approval-card.status-approved {
+ --approval-bg-color: hsl(78, 55%, 90%);
+ --approval-accent-color: hsl(78, 100%, 30%);
+ }
+ mr-approval-card.status-pending {
+ --approval-bg-color: hsl(40, 75%, 90%);
+ --approval-accent-color: hsl(33, 100%, 39%);
+ }
+ mr-approval-card.status-rejected {
+ --approval-bg-color: hsl(5, 60%, 92%);
+ --approval-accent-color: hsl(357, 100%, 39%);
+ }
+ mr-approval-card chops-button.edit-survey {
+ border: var(--chops-normal-border);
+ margin: 0;
+ }
+ mr-approval-card h3 {
+ margin: 0;
+ padding: 0;
+ display: inline;
+ font-weight: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ }
+ mr-approval-card mr-description {
+ display: block;
+ margin-bottom: 0.5em;
+ }
+ .approver-notice {
+ padding: 0.25em 0;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+ justify-content: space-between;
+ border-bottom: 1px dotted hsl(0, 0%, 83%);
+ }
+ .card-content {
+ box-sizing: border-box;
+ padding: 0.5em 16px;
+ padding-bottom: 1em;
+ }
+ .expand-icon {
+ display: block;
+ margin-right: 8px;
+ color: hsl(0, 0%, 45%);
+ }
+ mr-approval-card .header {
+ margin: 0;
+ width: 100%;
+ border: 0;
+ font-size: var(--chops-large-font-size);
+ font-weight: normal;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ padding: 0.5em 8px;
+ background-color: var(--approval-bg-color);
+ cursor: pointer;
+ }
+ mr-approval-card .status {
+ font-size: var(--chops-main-font-size);
+ color: var(--approval-accent-color);
+ display: inline-flex;
+ align-items: center;
+ margin-left: 32px;
+ }
+ mr-approval-card .survey {
+ padding: 0.5em 0;
+ max-height: 500px;
+ overflow-y: auto;
+ max-width: 100%;
+ box-sizing: border-box;
+ }
+ mr-approval-card [role="heading"] {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: flex-end;
+ }
+ mr-approval-card .edit-header {
+ margin-top: 40px;
+ }
+ </style>
+ <button
+ class="header"
+ @click=${this.toggleCard}
+ aria-expanded=${(this.opened || false).toString()}
+ >
+ <i class="material-icons expand-icon">
+ ${this.opened ? 'expand_less' : 'expand_more'}
+ </i>
+ <h3>${this.fieldName}</h3>
+ <span class="status">
+ <i class="material-icons status-icon" role="presentation">
+ ${CLASS_ICON_MAP[this._statusClass]}
+ </i>
+ ${this._status}
+ </span>
+ </button>
+ <chops-collapse class="card-content" ?opened=${this.opened}>
+ <div class="approver-notice">
+ ${this._isApprover ? html`
+ You are an approver for this bit.
+ `: ''}
+ ${this.user && this.user.isSiteAdmin ? html`
+ Your site admin privileges give you full access to edit this approval.
+ `: ''}
+ </div>
+ <mr-metadata
+ aria-label="${this.fieldName} Approval Metadata"
+ .approvalStatus=${this._status}
+ .approvers=${this.approvers}
+ .setter=${this.setter}
+ .fieldDefs=${this.fieldDefs}
+ .builtInFieldSpec=${APPROVAL_METADATA_FIELDS}
+ isApproval
+ ></mr-metadata>
+ <h4
+ class="medium-heading"
+ role="heading"
+ >
+ ${this.fieldName} Survey
+ <chops-button class="edit-survey" @click=${this._openSurveyEditor}>
+ Edit responses
+ </chops-button>
+ </h4>
+ <mr-description
+ class="survey"
+ .descriptionList=${this._allSurveys}
+ ></mr-description>
+ <mr-comment-list
+ headingLevel=4
+ .comments=${this.comments}
+ ></mr-comment-list>
+ ${this.issuePermissions.includes('addissuecomment') ? html`
+ <h4 id="edit${this.fieldName}" class="medium-heading edit-header">
+ Editing approval: ${this.phaseName} > ${this.fieldName}
+ </h4>
+ <mr-edit-metadata
+ .formName="${this.phaseName} > ${this.fieldName}"
+ .approvers=${this.approvers}
+ .fieldDefs=${this.fieldDefs}
+ .statuses=${this._availableStatuses}
+ .status=${this._status}
+ .error=${this.updateError && (this.updateError.description || this.updateError.message)}
+ ?saving=${this.updatingApproval}
+ ?hasApproverPrivileges=${this._hasApproverPrivileges}
+ isApproval
+ @save=${this.save}
+ @discard=${this.reset}
+ ></mr-edit-metadata>
+ ` : ''}
+ </chops-collapse>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ fieldName: {type: String},
+ approvers: {type: Array},
+ phaseName: {type: String},
+ setter: {type: Object},
+ fieldDefs: {type: Array},
+ focusId: {type: String},
+ user: {type: Object},
+ issue: {type: Object},
+ issueRef: {type: Object},
+ issuePermissions: {type: Array},
+ projectConfig: {type: Object},
+ comments: {type: String},
+ opened: {
+ type: Boolean,
+ reflect: true,
+ },
+ statusEnum: {type: String},
+ updatingApproval: {type: Boolean},
+ updateError: {type: Object},
+ _allSurveys: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.opened = false;
+ this.comments = [];
+ this.fieldDefs = [];
+ this.issuePermissions = [];
+ this._allSurveys = [];
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ const fieldDefsByApproval = projectV0.fieldDefsByApprovalName(state);
+ if (fieldDefsByApproval && this.fieldName &&
+ fieldDefsByApproval.has(this.fieldName)) {
+ this.fieldDefs = fieldDefsByApproval.get(this.fieldName);
+ }
+ const commentsByApproval = issueV0.commentsByApprovalName(state);
+ if (commentsByApproval && this.fieldName &&
+ commentsByApproval.has(this.fieldName)) {
+ const comments = commentsByApproval.get(this.fieldName);
+ this.comments = comments.slice(1);
+ this._allSurveys = commentListToDescriptionList(comments);
+ }
+ this.focusId = ui.focusId(state);
+ this.user = userV0.currentUser(state);
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.issuePermissions = issueV0.permissions(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.updatingApproval = issueV0.requests(state).updateApproval.requesting;
+ this.updateError = issueV0.requests(state).updateApproval.error;
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if ((changedProperties.has('comments') ||
+ changedProperties.has('focusId')) && this.comments) {
+ const focused = this.comments.find(
+ (comment) => `c${comment.sequenceNum}` === this.focusId);
+ if (focused) {
+ // Make sure to open the card when a comment is focused.
+ this.opened = true;
+ }
+ }
+ if (changedProperties.has('statusEnum')) {
+ this.setAttribute('class', this._statusClass);
+ }
+ if (changedProperties.has('user') || changedProperties.has('approvers')) {
+ if (this._isApprover) {
+ // Open the card by default if the user is an approver.
+ this.opened = true;
+ }
+ }
+ super.update(changedProperties);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('issue')) {
+ this.reset();
+ }
+ }
+
+ /**
+ * Resets the approval edit form.
+ */
+ reset() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+ form.reset();
+ }
+
+ /**
+ * Saves the user's changes in the approval update form.
+ */
+ async save() {
+ const form = this.querySelector('mr-edit-metadata');
+ const delta = form.delta;
+
+ if (delta.status) {
+ delta.status = TEXT_TO_STATUS_ENUM[delta.status];
+ }
+
+ // TODO(ehmaldonado): Show snackbar on change, and prevent starring issues
+ // to resetting the form.
+
+ const message = {
+ issueRef: this.issueRef,
+ fieldRef: {
+ type: fieldTypes.APPROVAL_TYPE,
+ fieldName: this.fieldName,
+ },
+ approvalDelta: delta,
+ commentContent: form.getCommentContent(),
+ sendEmail: form.sendEmail,
+ };
+
+ // Add files to message.
+ const uploads = await form.getAttachments();
+
+ if (uploads && uploads.length) {
+ message.uploads = uploads;
+ }
+
+ if (message.commentContent || message.approvalDelta || message.uploads) {
+ store.dispatch(issueV0.updateApproval(message));
+ }
+ }
+
+ /**
+ * Opens and closes the approval card.
+ */
+ toggleCard() {
+ this.opened = !this.opened;
+ }
+
+ /**
+ * @return {string} The CSS class used to style the approval card,
+ * given its status.
+ * @private
+ */
+ get _statusClass() {
+ return STATUS_CLASS_MAP[this._status];
+ }
+
+ /**
+ * @return {string} The human readable value of an approval status.
+ * @private
+ */
+ get _status() {
+ return STATUS_ENUM_TO_TEXT[this.statusEnum || ''];
+ }
+
+ /**
+ * @return {boolean} Whether the user is an approver or not.
+ * @private
+ */
+ get _isApprover() {
+ // Assumption: Since a user who is an approver should always be a project
+ // member, displayNames should be visible to them if they are an approver.
+ if (!this.approvers || !this.user || !this.user.displayName) return false;
+ const userGroups = this.user.groups || [];
+ return !!this.approvers.find((a) => {
+ return a.displayName === this.user.displayName || userGroups.find(
+ (group) => group.displayName === a.displayName,
+ );
+ });
+ }
+
+ /**
+ * @return {boolean} Whether the user can approver the approval or not.
+ * Not the same as _isApprover because site admins can approve approvals
+ * even if they are not approvers.
+ * @private
+ */
+ get _hasApproverPrivileges() {
+ return (this.user && this.user.isSiteAdmin) || this._isApprover;
+ }
+
+ /**
+ * @return {Array<StatusDef>}
+ * @private
+ */
+ get _availableStatuses() {
+ return APPROVAL_STATUSES.filter((s) => {
+ if (s.status === this._status) {
+ // The current status should always appear as an option.
+ return true;
+ }
+
+ if (!this._hasApproverPrivileges &&
+ APPROVER_RESTRICTED_STATUSES.has(s.status)) {
+ // If you are not an approver and and this status is restricted,
+ // you can't change to this status.
+ return false;
+ }
+
+ // No one can set statuses to NotSet, not even approvers.
+ return s.status !== 'NotSet';
+ });
+ }
+
+ /**
+ * Launches the description editing dialog for the survey.
+ * @fires CustomEvent#open-dialog
+ * @private
+ */
+ _openSurveyEditor() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'edit-description',
+ fieldName: this.fieldName,
+ },
+ }));
+ }
+}
+
+customElements.define('mr-approval-card', MrApprovalCard);
diff --git a/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js
new file mode 100644
index 0000000..0424c21
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js
@@ -0,0 +1,245 @@
+// 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 {MrApprovalCard} from './mr-approval-card.js';
+
+let element;
+
+describe('mr-approval-card', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-approval-card');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrApprovalCard);
+ });
+
+ it('_isApprover true when user is an approver', () => {
+ // User not in approver list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'test@notuser.com'},
+ {displayName: 'hello@world.com'},
+ ];
+ element.user = {displayName: 'test@user.com', groups: []};
+ assert.isFalse(element._isApprover);
+
+ // Use is in approver list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'test@notuser.com'},
+ {displayName: 'hello@world.com'},
+ {displayName: 'test@user.com'},
+ ];
+ assert.isTrue(element._isApprover);
+
+ // User's group is not in the list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'nongroup@group.com'},
+ {displayName: 'group@nongroup.com'},
+ {displayName: 'ignore@test.com'},
+ ];
+ element.user = {
+ displayName: 'test@user.com',
+ groups: [
+ {displayName: 'group@group.com'},
+ {displayName: 'test@group.com'},
+ {displayName: 'group@user.com'},
+ ],
+ };
+ assert.isFalse(element._isApprover);
+
+ // User's group is in the list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'group@group.com'},
+ {displayName: 'test@notuser.com'},
+ ];
+ element.user = {
+ displayName: 'test@user.com',
+ groups: [
+ {displayName: 'group@group.com'},
+ ],
+ };
+ assert.isTrue(element._isApprover);
+ });
+
+ it('approvals change color based on status', async () => {
+ // Initialize dependent CSS property from a stylesheet not included in
+ // our testing environment.
+ element.style.setProperty('--chops-purple-50', '#f3e5f5');
+
+ element.statusEnum = 'NEEDS_REVIEW';
+ await element.updateComplete;
+
+ const header = element.querySelector('button.header');
+
+ // Purple. Note that Chrome uses RGB for computed styles regardless of
+ // underlying CSS.
+ assert.equal(
+ window.getComputedStyle(header).getPropertyValue('background-color'),
+ 'rgb(243, 229, 245)');
+
+ element.statusEnum = 'APPROVED';
+ await element.updateComplete;
+
+ // Green.
+ assert.equal(
+ window.getComputedStyle(header).getPropertyValue('background-color'),
+ 'rgb(235, 244, 215)');
+ });
+
+ it('site admins have approver privileges', async () => {
+ await element.updateComplete;
+
+ const notice = element.querySelector('.approver-notice');
+ assert.equal(notice.textContent.trim(), '');
+
+ element.user = {isSiteAdmin: true};
+ await element.updateComplete;
+
+ assert.isTrue(element._hasApproverPrivileges);
+
+ assert.equal(notice.textContent.trim(),
+ 'Your site admin privileges give you full access to edit this approval.',
+ );
+ });
+
+ it('site admins see all approval statuses except NotSet', () => {
+ element.user = {isSiteAdmin: true};
+
+ assert.isFalse(element._isApprover);
+
+ element.statusEnum = 'NEEDS_REVIEW';
+
+ assert.equal(element._availableStatuses.length, 7);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'NA');
+ assert.equal(element._availableStatuses[2].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[3].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[4].status, 'NeedInfo');
+ assert.equal(element._availableStatuses[5].status, 'Approved');
+ assert.equal(element._availableStatuses[6].status, 'NotApproved');
+ });
+
+ it('approvers see all approval statuses except NotSet', () => {
+ element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+ element.approvers = [{displayName: 'test@email.com'}];
+
+ assert.isTrue(element._isApprover);
+
+ element.statusEnum = 'NEEDS_REVIEW';
+
+ assert.equal(element._availableStatuses.length, 7);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'NA');
+ assert.equal(element._availableStatuses[2].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[3].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[4].status, 'NeedInfo');
+ assert.equal(element._availableStatuses[5].status, 'Approved');
+ assert.equal(element._availableStatuses[6].status, 'NotApproved');
+ });
+
+ it('non-approvers see non-restricted approval statuses', () => {
+ element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+ element.approvers = [{displayName: 'test@otheremail.com'}];
+
+ assert.isFalse(element._isApprover);
+
+ element.statusEnum = 'NEEDS_REVIEW';
+
+ assert.equal(element._availableStatuses.length, 4);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[2].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[3].status, 'NeedInfo');
+ });
+
+ it('non-approvers see restricted approval status when set', () => {
+ element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+ element.approvers = [{displayName: 'test@otheremail.com'}];
+
+ assert.isFalse(element._isApprover);
+
+ element.statusEnum = 'APPROVED';
+
+ assert.equal(element._availableStatuses.length, 5);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[2].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[3].status, 'NeedInfo');
+ assert.equal(element._availableStatuses[4].status, 'Approved');
+ });
+
+ it('expands to show focused comment', async () => {
+ element.focusId = 'c4';
+ element.fieldName = 'field';
+ element.comments = [
+ {
+ sequenceNum: 1,
+ approvalRef: {fieldName: 'other-field'},
+ },
+ {
+ sequenceNum: 2,
+ approvalRef: {fieldName: 'field'},
+ },
+ {
+ sequenceNum: 3,
+ },
+ {
+ sequenceNum: 4,
+ approvalRef: {fieldName: 'field'},
+ },
+ ];
+
+ await element.updateComplete;
+
+ assert.isTrue(element.opened);
+ });
+
+ it('does not expand to show focused comment on other elements', async () => {
+ element.focusId = 'c3';
+ element.comments = [
+ {
+ sequenceNum: 1,
+ approvalRef: {fieldName: 'other-field'},
+ },
+ {
+ sequenceNum: 2,
+ approvalRef: {fieldName: 'field'},
+ },
+ {
+ sequenceNum: 4,
+ approvalRef: {fieldName: 'field'},
+ },
+ ];
+
+ await element.updateComplete;
+
+ assert.isFalse(element.opened);
+ });
+
+ it('mr-edit-metadata is displayed if user has addissuecomment', async () => {
+ element.issuePermissions = ['addissuecomment'];
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.querySelector('mr-edit-metadata'));
+ });
+
+ it('mr-edit-metadata is hidden if user has no addissuecomment', async () => {
+ element.issuePermissions = [];
+
+ await element.updateComplete;
+
+ assert.isNull(element.querySelector('mr-edit-metadata'));
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
new file mode 100644
index 0000000..aad9a8a
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
@@ -0,0 +1,163 @@
+// 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 {cache} from 'lit-html/directives/cache.js';
+import {LitElement, html, css} from 'lit-element';
+
+import '../../chops/chops-button/chops-button.js';
+import './mr-comment.js';
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import {userIsMember} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-comment-list>`
+ *
+ * Display a list of Monorail comments.
+ *
+ */
+export class MrCommentList extends connectStore(LitElement) {
+ /** @override */
+ constructor() {
+ super();
+
+ this.commentsShownCount = 2;
+ this.comments = [];
+ this.headingLevel = 4;
+
+ this.focusId = null;
+
+ this.usersProjects = new Map();
+
+ this._hideComments = true;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ commentsShownCount: {type: Number},
+ comments: {type: Array},
+ headingLevel: {type: Number},
+
+ focusId: {type: String},
+
+ usersProjects: {type: Object},
+
+ _hideComments: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.focusId = ui.focusId(state);
+ this.usersProjects = userV0.projectsPerUser(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (!this._hideComments) return;
+
+ // If any hidden comment is focused, show all hidden comments.
+ const hiddenCount =
+ _hiddenCount(this.comments.length, this.commentsShownCount);
+ const hiddenComments = this.comments.slice(0, hiddenCount);
+ for (const comment of hiddenComments) {
+ if ('c' + comment.sequenceNum === this.focusId) {
+ this._hideComments = false;
+ break;
+ }
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [SHARED_STYLES, css`
+ button.toggle {
+ background: none;
+ color: var(--chops-link-color);
+ border: 0;
+ border-bottom: var(--chops-normal-border);
+ border-top: var(--chops-normal-border);
+ width: 100%;
+ padding: 0.5em 8px;
+ text-align: left;
+ font-size: var(--chops-main-font-size);
+ }
+ button.toggle:hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ button.toggle[hidden] {
+ display: none;
+ }
+ .edit-slot {
+ margin-top: 3em;
+ }
+ `];
+ }
+
+ /** @override */
+ render() {
+ const hiddenCount =
+ _hiddenCount(this.comments.length, this.commentsShownCount);
+ return html`
+ <button @click=${this._toggleHide}
+ class="toggle"
+ ?hidden=${hiddenCount <= 0}>
+ ${this._hideComments ? 'Show' : 'Hide'}
+ ${hiddenCount}
+ older
+ ${hiddenCount == 1 ? 'comment' : 'comments'}
+ </button>
+ ${cache(this._hideComments ? '' :
+ html`${this.comments.slice(0, hiddenCount).map(
+ this.renderComment.bind(this))}`)}
+ ${this.comments.slice(hiddenCount).map(this.renderComment.bind(this))}
+ `;
+ }
+
+ /**
+ * Helper to render a single comment.
+ * @param {Comment} comment
+ * @return {TemplateResult}
+ */
+ renderComment(comment) {
+ const commenterIsMember = userIsMember(
+ comment.commenter, comment.projectName, this.usersProjects);
+ return html`
+ <mr-comment
+ .comment=${comment}
+ headingLevel=${this.headingLevel}
+ ?highlighted=${'c' + comment.sequenceNum === this.focusId}
+ ?commenterIsMember=${commenterIsMember}
+ ></mr-comment>`;
+ }
+
+ /**
+ * Hides or unhides comments that are hidden by default. For example,
+ * if an issue has 200 comments, the first 100 comments are shown initially,
+ * then the last 100 can be toggled to be shown.
+ * @private
+ */
+ _toggleHide() {
+ this._hideComments = !this._hideComments;
+ }
+}
+
+/**
+ * Computes how many comments the user is able to expand.
+ * @param {number} commentCount Total comments.
+ * @param {number} commentsShownCount The number of comments shown.
+ * @return {number} The number of hidden comments.
+ * @private
+ */
+function _hiddenCount(commentCount, commentsShownCount) {
+ return Math.max(commentCount - commentsShownCount, 0);
+}
+
+customElements.define('mr-comment-list', MrCommentList);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
new file mode 100644
index 0000000..548b7a7
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
@@ -0,0 +1,108 @@
+// 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 {MrCommentList} from './mr-comment-list.js';
+
+
+let element;
+
+describe('mr-comment-list', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-comment-list');
+ document.body.appendChild(element);
+ element.comments = [
+ {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 1,
+ timestamp: 1549319989,
+ },
+ {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 2,
+ timestamp: 1549320089,
+ },
+ {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 3,
+ timestamp: 1549320189,
+ },
+ ];
+
+ // Stub RAF to execute immediately.
+ sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ window.requestAnimationFrame.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrCommentList);
+ });
+
+ it('scrolls to comment', async () => {
+ await element.updateComplete;
+
+ const commentElements = element.shadowRoot.querySelectorAll('mr-comment');
+ const commentElement = commentElements[commentElements.length - 1];
+ sinon.stub(commentElement, 'scrollIntoView');
+
+ element.focusId = 'c3';
+
+ await element.updateComplete;
+
+ assert.isTrue(element._hideComments);
+ assert.isTrue(commentElement.scrollIntoView.calledOnce);
+
+ commentElement.scrollIntoView.restore();
+ });
+
+ it('scrolls to hidden comment', async () => {
+ await element.updateComplete;
+
+ element.focusId = 'c1';
+
+ await element.updateComplete;
+
+ assert.isFalse(element._hideComments);
+ // TODO: Check that the comment has been scrolled into view.
+ });
+
+ it('doesnt scroll to unknown comment', async () => {
+ await element.updateComplete;
+
+ element.focusId = 'c100';
+
+ await element.updateComplete;
+
+ assert.isTrue(element._hideComments);
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
new file mode 100644
index 0000000..e56bef3
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
@@ -0,0 +1,416 @@
+// 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 {store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import 'elements/framework/mr-comment-content/mr-comment-content.js';
+import 'elements/framework/mr-comment-content/mr-attachment.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {issueStringToRef} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+const ISSUE_REF_FIELD_NAMES = [
+ 'Blocking',
+ 'Blockedon',
+ 'Mergedinto',
+];
+
+/**
+ * `<mr-comment>`
+ *
+ * A component for an individual comment.
+ *
+ */
+export class MrComment extends LitElement {
+ /** @override */
+ constructor() {
+ super();
+
+ this._isExpandedIfDeleted = false;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ comment: {type: Object},
+ headingLevel: {type: String},
+ highlighted: {
+ type: Boolean,
+ reflect: true,
+ },
+ commenterIsMember: {type: Boolean},
+ _isExpandedIfDeleted: {type: Boolean},
+ _showOriginalContent: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('highlighted') && this.highlighted) {
+ window.requestAnimationFrame(() => {
+ this.scrollIntoView();
+ // TODO(ehmaldonado): Figure out a way to get the height from the issue
+ // header, and scroll by that amount.
+ window.scrollBy(0, -150);
+ });
+ }
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: block;
+ margin: 1.5em 0 0 0;
+ }
+ :host([highlighted]) {
+ border: 1px solid var(--chops-primary-accent-color);
+ box-shadow: 0 0 4px 4px var(--chops-active-choice-bg);
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ .comment-header {
+ background: var(--chops-card-heading-bg);
+ padding: 3px 1px 1px 8px;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ box-sizing: border-box;
+ }
+ .comment-header a {
+ display: inline-flex;
+ }
+ .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;
+ }
+ .comment-options {
+ float: right;
+ text-align: right;
+ text-decoration: none;
+ }
+ .comment-body {
+ margin: 4px;
+ box-sizing: border-box;
+ }
+ .deleted-comment-notice {
+ margin-left: 4px;
+ }
+ .issue-diff {
+ background: var(--chops-card-details-bg);
+ display: inline-block;
+ padding: 4px 8px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ ${this._renderHeading()}
+ ${_shouldShowComment(this._isExpandedIfDeleted, this.comment) ? html`
+ ${this._renderDiff()}
+ ${this._renderBody()}
+ ` : ''}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderHeading() {
+ return html`
+ <div
+ role="heading"
+ aria-level=${this.headingLevel}
+ class="comment-header">
+ <div>
+ <a
+ href="?id=${this.comment.localId}#c${this.comment.sequenceNum}"
+ class="comment-link"
+ >Comment ${this.comment.sequenceNum}</a>
+
+ ${this._renderByline()}
+ </div>
+ ${_shouldOfferCommentOptions(this.comment) ? html`
+ <div class="comment-options">
+ <mr-dropdown
+ .items=${this._commentOptions}
+ label="Comment options"
+ icon="more_vert"
+ ></mr-dropdown>
+ </div>
+ ` : ''}
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderByline() {
+ if (_shouldShowComment(this._isExpandedIfDeleted, this.comment)) {
+ return html`
+ by
+ <mr-user-link .userRef=${this.comment.commenter}></mr-user-link>
+ on
+ <chops-timestamp
+ .timestamp=${this.comment.timestamp}
+ ></chops-timestamp>
+ ${this.commenterIsMember && !this.comment.isDeleted ? html`
+ <span class="role-label">Project Member</span>` : ''}
+ `;
+ } else {
+ return html`<span class="deleted-comment-notice">Deleted</span>`;
+ }
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderDiff() {
+ if (!(this.comment.descriptionNum || this.comment.amendments)) return '';
+
+ return html`
+ <div class="issue-diff">
+ ${(this.comment.amendments || []).map((delta) => html`
+ <strong>${delta.fieldName}:</strong>
+ ${_issuesForAmendment(delta, this.comment.projectName).map((issueForAmendment) => html`
+ <mr-issue-link
+ projectName=${this.comment.projectName}
+ .issue=${issueForAmendment.issue}
+ text=${issueForAmendment.text}
+ ></mr-issue-link>
+ `)}
+ ${!_amendmentHasIssueRefs(delta.fieldName) ? delta.newOrDeltaValue : ''}
+ ${delta.oldValue ? `(was: ${delta.oldValue})` : ''}
+ <br>
+ `)}
+ ${this.comment.descriptionNum ? 'Description was changed.' : ''}
+ </div><br>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderBody() {
+ const commentContent = this._showOriginalContent ?
+ this.comment.inboundMessage :
+ this.comment.content;
+ return html`
+ <div class="comment-body">
+ <mr-comment-content
+ ?hidden=${this.comment.descriptionNum}
+ .content=${commentContent}
+ .author=${this.comment.commenter.displayName}
+ ?isDeleted=${this.comment.isDeleted}
+ ></mr-comment-content>
+ <div ?hidden=${this.comment.descriptionNum}>
+ ${(this.comment.attachments || []).map((attachment) => html`
+ <mr-attachment
+ .attachment=${attachment}
+ projectName=${this.comment.projectName}
+ localId=${this.comment.localId}
+ sequenceNum=${this.comment.sequenceNum}
+ ?canDelete=${this.comment.canDelete}
+ ></mr-attachment>
+ `)}
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Displays three dot menu options available to the current user for a given
+ * comment.
+ * @return {Array<MenuItem>}
+ */
+ get _commentOptions() {
+ const options = [];
+ if (_canExpandDeletedComment(this.comment)) {
+ const text =
+ (this._isExpandedIfDeleted ? 'Hide' : 'Show') + ' comment content';
+ options.push({
+ text: text,
+ handler: this._toggleHideDeletedComment.bind(this),
+ });
+ options.push({separator: true});
+ }
+ if (this.comment.canDelete) {
+ const text =
+ (this.comment.isDeleted ? 'Undelete' : 'Delete') + ' comment';
+ options.push({
+ text: text,
+ handler: _deleteComment.bind(null, this.comment),
+ });
+ }
+ if (this.comment.canFlag) {
+ const text = (this.comment.isSpam ? 'Unflag' : 'Flag') + ' comment';
+ options.push({
+ text: text,
+ handler: _flagComment.bind(null, this.comment),
+ });
+ }
+ if (this.comment.inboundMessage) {
+ const text =
+ (this._showOriginalContent ? 'Hide' : 'Show') + ' original email';
+ options.push({
+ text: text,
+ handler: this._toggleShowOriginalContent.bind(this),
+ });
+ }
+ return options;
+ }
+
+ /**
+ * Toggles whether the email of the user who deleted the comment should be
+ * shown.
+ */
+ _toggleShowOriginalContent() {
+ this._showOriginalContent = !this._showOriginalContent;
+ }
+
+ /**
+ * Change if deleted content for a comment is shown or not.
+ */
+ _toggleHideDeletedComment() {
+ this._isExpandedIfDeleted = !this._isExpandedIfDeleted;
+ }
+}
+
+/**
+ * Says whether a comment should be shown or not.
+ * @param {boolean} isExpandedIfDeleted If the user has chosen to see the
+ * deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean} If the comment should be shown.
+ */
+function _shouldShowComment(isExpandedIfDeleted, comment) {
+ return !comment.isDeleted || isExpandedIfDeleted;
+}
+
+/**
+ * Whether the user can view additional comment options like flagging or
+ * deleting.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _shouldOfferCommentOptions(comment) {
+ return comment.canDelete || comment.canFlag;
+}
+
+/**
+ * Whether a user has permission to view a given deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _canExpandDeletedComment(comment) {
+ return ((comment.isSpam && comment.canFlag) ||
+ (comment.isDeleted && comment.canDelete));
+}
+
+/**
+ * Deletes a given comment or undeletes it if it's already deleted.
+ * @param {IssueComment} comment The comment to delete.
+ */
+async function _deleteComment(comment) {
+ const issueRef = {
+ projectName: comment.projectName,
+ localId: comment.localId,
+ };
+ await prpcClient.call('monorail.Issues', 'DeleteIssueComment', {
+ issueRef,
+ sequenceNum: comment.sequenceNum,
+ delete: comment.isDeleted === undefined,
+ });
+ store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Sends a request to flag a comment as spam. Flags or unflags based on
+ * the comments existing isSpam state.
+ * @param {IssueComment} comment The comment to flag.
+ */
+async function _flagComment(comment) {
+ const issueRef = {
+ projectName: comment.projectName,
+ localId: comment.localId,
+ };
+ await prpcClient.call('monorail.Issues', 'FlagComment', {
+ issueRef,
+ sequenceNum: comment.sequenceNum,
+ flag: comment.isSpam === undefined,
+ });
+ store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Finds if a given change in a comment contains issues (ie: for Blocking or
+ * BlockedOn edits), then formats those issues into a list to be rendered by the
+ * frontend.
+ * @param {Amendment} delta
+ * @param {string} projectName The project name the user is currently viewing.
+ * @return {Array<{issue: Issue, text: string}>}
+ */
+function _issuesForAmendment(delta, projectName) {
+ if (!_amendmentHasIssueRefs(delta.fieldName) ||
+ !delta.newOrDeltaValue) {
+ return [];
+ }
+ // TODO(ehmaldonado): Request the issue to check for permissions and display
+ // the issue summary.
+ return delta.newOrDeltaValue.split(' ').map((deltaValue) => {
+ let refString = deltaValue;
+
+ // When an issue is removed, its ID is prepended with a minus sign.
+ if (refString.startsWith('-')) {
+ refString = refString.substr(1);
+ }
+ const issueRef = issueStringToRef(refString, projectName);
+ return {
+ issue: {
+ ...issueRef,
+ },
+ text: deltaValue,
+ };
+ });
+}
+
+/**
+ * Check if a field is one of the field types that accepts issues as input.
+ * @param {string} fieldName
+ * @return {boolean} If the field contains issues.
+ */
+function _amendmentHasIssueRefs(fieldName) {
+ return ISSUE_REF_FIELD_NAMES.includes(fieldName);
+}
+
+customElements.define('mr-comment', MrComment);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
new file mode 100644
index 0000000..6933825
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
@@ -0,0 +1,257 @@
+// 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 {MrComment} from './mr-comment.js';
+
+
+let element;
+
+/**
+ * Testing helper to find if an Array of options has an option with some
+ * text.
+ * @param {Array<MenuItem>} options Dropdown options to look through.
+ * @param {string} needle The text to search for.
+ * @return {boolean} Whether the option exists or not.
+ */
+const hasOptionWithText = (options, needle) => {
+ return options.some(({text}) => text === needle);
+};
+
+describe('mr-comment', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-comment');
+ element.comment = {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 3,
+ timestamp: 1549319989,
+ };
+ document.body.appendChild(element);
+
+ // Stub RAF to execute immediately.
+ sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ window.requestAnimationFrame.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrComment);
+ });
+
+ it('scrolls to comment', async () => {
+ sinon.stub(element, 'scrollIntoView');
+
+ element.highlighted = true;
+ await element.updateComplete;
+
+ assert.isTrue(element.scrollIntoView.calledOnce);
+
+ element.scrollIntoView.restore();
+ });
+
+ it('comment header renders self link to comment', async () => {
+ element.comment = {
+ localId: 1,
+ projectName: 'test',
+ sequenceNum: 2,
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('.comment-link');
+
+ assert.equal(link.textContent, 'Comment 2');
+ assert.include(link.href, '?id=1#c2');
+ });
+
+ it('renders issue links for Blockedon issue amendments', async () => {
+ element.comment = {
+ projectName: 'test',
+ amendments: [
+ {
+ fieldName: 'Blockedon',
+ newOrDeltaValue: '-2 3',
+ },
+ ],
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+ assert.equal(links.length, 2);
+
+ assert.equal(links[0].text, '-2');
+ assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+ assert.equal(links[1].text, '3');
+ assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+ });
+
+ it('renders issue links for Blocking issue amendments', async () => {
+ element.comment = {
+ projectName: 'test',
+ amendments: [
+ {
+ fieldName: 'Blocking',
+ newOrDeltaValue: '-2 3',
+ },
+ ],
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+ assert.equal(links.length, 2);
+
+ assert.equal(links[0].text, '-2');
+ assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+ assert.equal(links[1].text, '3');
+ assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+ });
+
+ it('renders issue links for Mergedinto issue amendments', async () => {
+ element.comment = {
+ projectName: 'test',
+ amendments: [
+ {
+ fieldName: 'Mergedinto',
+ newOrDeltaValue: '-2 3',
+ },
+ ],
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+ assert.equal(links.length, 2);
+
+ assert.equal(links[0].text, '-2');
+ assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+ assert.equal(links[1].text, '3');
+ assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+ });
+
+ describe('3-dot menu options', () => {
+ it('allows showing deleted comment content', () => {
+ element._isExpandedIfDeleted = false;
+
+ // The comment is deleted.
+ element.comment = {content: 'test', isDeleted: true, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Show comment content'));
+
+ // The comment is spam.
+ element.comment = {content: 'test', isSpam: true, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Show comment content'));
+ });
+
+ it('allows hiding deleted comment content', () => {
+ element._isExpandedIfDeleted = true;
+
+ // The comment is deleted.
+ element.comment = {content: 'test', isDeleted: true, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+
+ // The comment is spam.
+ element.comment = {content: 'test', isSpam: true, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+ });
+
+ it('disallows showing deleted comment content', () => {
+ // The comment is deleted.
+ element.comment = {content: 'test', isDeleted: true, canDelete: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+
+ // The comment is spam.
+ element.comment = {content: 'test', isSpam: true, canFlag: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+ });
+
+ it('allows deleting comment', () => {
+ element.comment = {content: 'test', isDeleted: false, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Delete comment'));
+ });
+
+ it('disallows deleting comment', () => {
+ element.comment = {content: 'test', isDeleted: false, canDelete: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Delete comment'));
+ });
+
+ it('allows undeleting comment', () => {
+ element.comment = {content: 'test', isDeleted: true, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Undelete comment'));
+ });
+
+ it('disallows undeleting comment', () => {
+ element.comment = {content: 'test', isDeleted: true, canDelete: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Undelete comment'));
+ });
+
+ it('allows flagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: false, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Flag comment'));
+ });
+
+ it('disallows flagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: false, canFlag: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Flag comment'));
+ });
+
+ it('allows unflagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: true, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Unflag comment'));
+ });
+
+ it('disallows unflagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: true, canFlag: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Unflag comment'));
+ });
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-flipper/mr-flipper.js b/static_src/elements/issue-detail/mr-flipper/mr-flipper.js
new file mode 100644
index 0000000..8159e01
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-flipper/mr-flipper.js
@@ -0,0 +1,151 @@
+// 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 {connectStore} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * Class for displaying a single flipper.
+ * @extends {LitElement}
+ */
+export default class MrFlipper extends connectStore(LitElement) {
+ /** @override */
+ static get properties() {
+ return {
+ currentIndex: {type: Number},
+ totalCount: {type: Number},
+ prevUrl: {type: String},
+ nextUrl: {type: String},
+ listUrl: {type: String},
+ queryParams: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.currentIndex = null;
+ this.totalCount = null;
+ this.prevUrl = null;
+ this.nextUrl = null;
+ this.listUrl = null;
+
+ this.queryParams = {};
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.queryParams = sitewide.queryParams(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('queryParams')) {
+ this.fetchFlipperData(qs.stringify(this.queryParams));
+ }
+ }
+
+ // Eventually this should be replaced with pRPC.
+ fetchFlipperData(query) {
+ const options = {
+ credentials: 'include',
+ method: 'GET',
+ };
+ fetch(`detail/flipper?${query}`, options).then(
+ (response) => response.text(),
+ ).then(
+ (responseBody) => {
+ let responseData;
+ try {
+ // Strip XSSI prefix from response.
+ responseData = JSON.parse(responseBody.substr(5));
+ } catch (e) {
+ console.error(`Error parsing JSON response for flipper: ${e}`);
+ return;
+ }
+ this._populateResponseData(responseData);
+ },
+ );
+ }
+
+ _populateResponseData(data) {
+ this.totalCount = data.total_count;
+ this.currentIndex = data.cur_index;
+ this.prevUrl = data.prev_url;
+ this.nextUrl = data.next_url;
+ this.listUrl = data.list_url;
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ }
+ /* Use visibility instead of display:hidden for hiding in order to
+ * avoid popping when elements are made visible. */
+ .row a[hidden], .counts[hidden] {
+ visibility: hidden;
+ }
+ .counts[hidden] {
+ display: block;
+ }
+ .row a {
+ display: block;
+ padding: 0.25em 0;
+ }
+ .row a, .row div {
+ flex: 1;
+ white-space: nowrap;
+ padding: 0 2px;
+ }
+ .row .counts {
+ padding: 0 16px;
+ }
+ .row {
+ display: flex;
+ align-items: baseline;
+ text-align: center;
+ flex-direction: row;
+ }
+ @media (max-width: 960px) {
+ :host {
+ display: inline-block;
+ }
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <div class="row">
+ <a href="${this.prevUrl}" ?hidden="${!this.prevUrl}" title="Prev" class="prev-url">
+ ‹ Prev
+ </a>
+ <div class="counts" ?hidden=${!this.totalCount}>
+ ${this.currentIndex + 1} of ${this.totalCount}
+ </div>
+ <a href="${this.nextUrl}" ?hidden="${!this.nextUrl}" title="Next" class="next-url">
+ Next ›
+ </a>
+ </div>
+ <div class="row">
+ <a href="${this.listUrl}" ?hidden="${!this.listUrl}" title="Back to list" class="list-url">
+ Back to list
+ </a>
+ </div>
+ `;
+ }
+}
+
+window.customElements.define('mr-flipper', MrFlipper);
diff --git a/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js b/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js
new file mode 100644
index 0000000..183a8d5
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js
@@ -0,0 +1,75 @@
+// 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 MrFlipper from './mr-flipper.js';
+import sinon from 'sinon';
+
+const xssiPrefix = ')]}\'';
+
+let element;
+
+describe('mr-flipper', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-flipper');
+ document.body.appendChild(element);
+
+ sinon.stub(window, 'fetch');
+
+ const response = new window.Response(`${xssiPrefix}{"message": "Ok"}`, {
+ status: 201,
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ });
+ window.fetch.returns(Promise.resolve(response));
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ window.fetch.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrFlipper);
+ });
+
+ it('renders links', async () => {
+ // Test DOM after properties are updated.
+ element._populateResponseData({
+ cur_index: 4,
+ total_count: 13,
+ prev_url: 'http://prevurl/',
+ next_url: 'http://nexturl/',
+ list_url: 'http://listurl/',
+ });
+
+ await element.updateComplete;
+
+ const prevUrlEl = element.shadowRoot.querySelector('a.prev-url');
+ const nextUrlEl = element.shadowRoot.querySelector('a.next-url');
+ const listUrlEl = element.shadowRoot.querySelector('a.list-url');
+ const countsEl = element.shadowRoot.querySelector('div.counts');
+
+ assert.equal(prevUrlEl.href, 'http://prevurl/');
+ assert.equal(nextUrlEl.href, 'http://nexturl/');
+ assert.equal(listUrlEl.href, 'http://listurl/');
+ assert.include(countsEl.innerText, '5 of 13');
+ });
+
+ it('fetches flipper data when queryParams change', async () => {
+ await element.updateComplete;
+
+ sinon.stub(element, 'fetchFlipperData');
+
+ element.queryParams = {id: 21, q: 'owner:me'};
+
+ sinon.assert.notCalled(element.fetchFlipperData);
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchFlipperData, 'id=21&q=owner%3Ame');
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js
new file mode 100644
index 0000000..bd88b3f
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js
@@ -0,0 +1,162 @@
+// 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} from 'lit-element';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as ui from 'reducers/ui.js';
+import 'elements/framework/mr-comment-content/mr-description.js';
+import '../mr-comment-list/mr-comment-list.js';
+import '../metadata/mr-edit-metadata/mr-edit-issue.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+
+/**
+ * `<mr-issue-details>`
+ *
+ * This is the main details section for a given issue.
+ *
+ */
+export class MrIssueDetails extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ let comments = [];
+ let descriptions = [];
+
+ if (this.commentsByApproval && this.commentsByApproval.has('')) {
+ // Comments without an approval go into the main view.
+ const mainComments = this.commentsByApproval.get('');
+ comments = mainComments.slice(1);
+ descriptions = commentListToDescriptionList(mainComments);
+ }
+
+ return html`
+ <style>
+ mr-issue-details {
+ font-size: var(--chops-main-font-size);
+ background-color: var(--chops-white);
+ padding-bottom: 1em;
+ display: flex;
+ align-items: stretch;
+ justify-content: flex-start;
+ flex-direction: column;
+ margin: 0;
+ box-sizing: border-box;
+ }
+ h3 {
+ margin-top: 1em;
+ }
+ mr-description {
+ margin-bottom: 1em;
+ }
+ mr-edit-issue {
+ margin-top: 40px;
+ }
+ </style>
+ <mr-description .descriptionList=${descriptions}></mr-description>
+ <mr-comment-list
+ headingLevel="2"
+ .comments=${comments}
+ .commentsShownCount=${this.commentsShownCount}
+ ></mr-comment-list>
+ ${this.issuePermissions.includes('addissuecomment') ?
+ html`<mr-edit-issue></mr-edit-issue>` : ''}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ commentsByApproval: {type: Object},
+ commentsShownCount: {type: Number},
+ issuePermissions: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.commentsByApproval = new Map();
+ this.issuePermissions = [];
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.commentsByApproval = issueV0.commentsByApprovalName(state);
+ this.issuePermissions = issueV0.permissions(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ this._measureCommentLoadTime(changedProperties);
+ }
+
+ async _measureCommentLoadTime(changedProperties) {
+ if (!changedProperties.has('commentsByApproval')) {
+ return;
+ }
+ if (!this.commentsByApproval || this.commentsByApproval.size === 0) {
+ // For cold loads, if the GetIssue call returns before ListComments,
+ // commentsByApproval is initially set to an empty Map. Filter that out.
+ return;
+ }
+ const fullAppLoad = ui.navigationCount(store.getState()) === 1;
+ if (!(fullAppLoad || changedProperties.get('commentsByApproval'))) {
+ // For hot loads, the previous issue data is still in the Redux store, so
+ // the first update sets the comments to the previous issue's comments.
+ // We need to wait for the following update.
+ return;
+ }
+ const startMark = fullAppLoad ? undefined : 'start load issue detail page';
+ if (startMark && !performance.getEntriesByName(startMark).length) {
+ // Modifying the issue template, description, comments, or attachments
+ // triggers a comment update. We only want to include full issue loads.
+ return;
+ }
+
+ await Promise.all(_subtreeUpdateComplete(this));
+
+ const endMark = 'finish load issue detail comments';
+ performance.mark(endMark);
+
+ const measurementType = fullAppLoad ? 'from outside app' : 'within app';
+ const measurementName = `load issue detail page (${measurementType})`;
+ performance.measure(measurementName, startMark, endMark);
+
+ const measurement =
+ performance.getEntriesByName(measurementName)[0].duration;
+ window.getTSMonClient().recordIssueCommentsLoadTiming(
+ measurement, fullAppLoad);
+
+ // Be sure to clear this mark even on full page navigations.
+ performance.clearMarks('start load issue detail page');
+ performance.clearMarks(endMark);
+ performance.clearMeasures(measurementName);
+ }
+}
+
+/**
+ * Recursively traverses all shadow DOMs in an element subtree and returns an
+ * Array containing the updateComplete Promises for all lit-element nodes.
+ * @param {!LitElement} element
+ * @return {!Array<Promise<Boolean>>}
+ */
+function _subtreeUpdateComplete(element) {
+ if (!element.updateComplete) {
+ return [];
+ }
+
+ const context = element.shadowRoot ? element.shadowRoot : element;
+ const children = context.querySelectorAll('*');
+ const childPromises = Array.from(children, (e) => _subtreeUpdateComplete(e));
+ return [element.updateComplete].concat(...childPromises);
+}
+
+customElements.define('mr-issue-details', MrIssueDetails);
diff --git a/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js
new file mode 100644
index 0000000..3919e15
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js
@@ -0,0 +1,39 @@
+// 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 {MrIssueDetails} from './mr-issue-details.js';
+
+let element;
+
+describe('mr-issue-details', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-details');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueDetails);
+ });
+
+ it('mr-edit-issue is displayed if user has addissuecomment', async () => {
+ element.issuePermissions = ['addissuecomment'];
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.querySelector('mr-edit-issue'));
+ });
+
+ it('mr-edit-issue is hidden if user has no addissuecomment', async () => {
+ element.issuePermissions = [];
+
+ await element.updateComplete;
+
+ assert.isNull(element.querySelector('mr-edit-issue'));
+ });
+});
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);
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
new file mode 100644
index 0000000..741baaa
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
@@ -0,0 +1,102 @@
+// 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} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import './mr-phase.js';
+
+/**
+ * `<mr-launch-overview>`
+ *
+ * This is a shorthand view of the phases for a user to see a quick overview.
+ *
+ */
+export class MrLaunchOverview extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <style>
+ mr-launch-overview {
+ max-width: 100%;
+ display: flex;
+ flex-flow: column;
+ justify-content: flex-start;
+ align-items: stretch;
+ }
+ mr-launch-overview[hidden] {
+ display: none;
+ }
+ mr-phase {
+ margin-bottom: 0.75em;
+ }
+ </style>
+ ${this.phases.map((phase) => html`
+ <mr-phase
+ .phaseName=${phase.phaseRef.phaseName}
+ .approvals=${this._approvalsForPhase(this.approvals, phase.phaseRef.phaseName)}
+ ></mr-phase>
+ `)}
+ ${this._phaselessApprovals.length ? html`
+ <mr-phase .approvals=${this._phaselessApprovals}></mr-phase>
+ `: ''}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ approvals: {type: Array},
+ phases: {type: Array},
+ hidden: {
+ type: Boolean,
+ reflect: true,
+ },
+ };
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.approvals = [];
+ this.phases = [];
+ this.hidden = true;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ if (!issueV0.viewedIssue(state)) return;
+
+ this.approvals = issueV0.viewedIssue(state).approvalValues || [];
+ this.phases = issueV0.viewedIssue(state).phases || [];
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('phases') || changedProperties.has('approvals')) {
+ this.hidden = !this.phases.length && !this.approvals.length;
+ }
+ super.update(changedProperties);
+ }
+
+ get _phaselessApprovals() {
+ return this._approvalsForPhase(this.approvals);
+ }
+
+ _approvalsForPhase(approvals, phaseName) {
+ return (approvals || []).filter((a) => {
+ // We can assume phase names will be unique.
+ return a.phaseRef.phaseName == phaseName;
+ });
+ }
+}
+customElements.define('mr-launch-overview', MrLaunchOverview);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js
new file mode 100644
index 0000000..3e2ff46
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js
@@ -0,0 +1,24 @@
+// 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 {MrLaunchOverview} from './mr-launch-overview.js';
+
+
+let element;
+
+describe('mr-launch-overview', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-launch-overview');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrLaunchOverview);
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
new file mode 100644
index 0000000..a81be65
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
@@ -0,0 +1,460 @@
+// 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} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import '../mr-approval-card/mr-approval-card.js';
+import {valueForField, valuesForField} from 'shared/metadata-helpers.js';
+import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
+import 'elements/issue-detail/metadata/mr-metadata/mr-field-values.js';
+
+const TARGET_PHASE_MILESTONE_MAP = {
+ 'Beta': 'feature_freeze',
+ 'Stable-Exp': 'final_beta_cut',
+ 'Stable': 'stable_cut',
+ 'Stable-Full': 'stable_cut',
+};
+
+const APPROVED_PHASE_MILESTONE_MAP = {
+ 'Beta': 'earliest_beta',
+ 'Stable-Exp': 'final_beta',
+ 'Stable': 'stable_date',
+ 'Stable-Full': 'stable_date',
+};
+
+// The following milestones are unique to ios.
+const IOS_APPROVED_PHASE_MILESTONE_MAP = {
+ 'Beta': 'earliest_beta_ios',
+};
+
+// See monorail:4692 and the use of PHASES_WITH_MILESTONES
+// in tracker/issueentry.py
+const PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full'];
+
+/**
+ * `<mr-phase>`
+ *
+ * This is the component for a single phase.
+ *
+ */
+export class MrPhase extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ const isPhaseWithMilestone = PHASES_WITH_MILESTONES.includes(
+ this.phaseName);
+ const noApprovals = !this.approvals || !this.approvals.length;
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <style>
+ mr-phase {
+ display: block;
+ }
+ mr-phase chops-dialog {
+ --chops-dialog-theme: {
+ width: 500px;
+ max-width: 100%;
+ };
+ }
+ mr-phase h2 {
+ margin: 0;
+ font-size: var(--chops-large-font-size);
+ font-weight: normal;
+ padding: 0.5em 8px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+ mr-phase h2 em {
+ margin-left: 16px;
+ font-size: var(--chops-main-font-size);
+ }
+ mr-phase .chip {
+ display: inline-block;
+ font-size: var(--chops-main-font-size);
+ padding: 0.25em 8px;
+ margin: 0 2px;
+ border-radius: 16px;
+ background: var(--chops-blue-gray-50);
+ }
+ .phase-edit {
+ padding: 0.1em 8px;
+ }
+ </style>
+ <h2>
+ <div>
+ Approvals<span ?hidden=${!this.phaseName || !this.phaseName.length}>:
+ ${this.phaseName}
+ </span>
+ ${isPhaseWithMilestone ? html`${this.fieldDefs &&
+ this.fieldDefs.map((field) => this._renderPhaseField(field))}
+ <em ?hidden=${!this._nextDate}>
+ ${this._dateDescriptor}
+ <chops-timestamp .timestamp=${this._nextDate}></chops-timestamp>
+ </em>
+ <em ?hidden=${!this._nextUniqueiOSDate}>
+ <b>iOS</b> ${this._dateDescriptor}
+ <chops-timestamp .timestamp=${this._nextUniqueiOSDate}
+ ></chops-timestamp>
+ </em>
+ `: ''}
+ </div>
+ ${isPhaseWithMilestone ? html`
+ <chops-button @click=${this.edit} class="de-emphasized phase-edit">
+ <i class="material-icons" role="presentation">create</i>
+ Edit
+ </chops-button>
+ `: ''}
+ </h2>
+ ${this.approvals && this.approvals.map((approval) => html`
+ <mr-approval-card
+ .approvers=${approval.approverRefs}
+ .setter=${approval.setterRef}
+ .fieldName=${approval.fieldRef.fieldName}
+ .phaseName=${this.phaseName}
+ .statusEnum=${approval.status}
+ .survey=${approval.survey}
+ .surveyTemplate=${approval.surveyTemplate}
+ .urls=${approval.urls}
+ .labels=${approval.labels}
+ .users=${approval.users}
+ ></mr-approval-card>
+ `)}
+ ${noApprovals ? html`No tasks for this phase.` : ''}
+ <!-- TODO(ehmaldonado): Move to /issue-detail/dialogs -->
+ <chops-dialog id="editPhase" aria-labelledby="phaseDialogTitle">
+ <h3 id="phaseDialogTitle" class="medium-heading">
+ Editing phase: ${this.phaseName}
+ </h3>
+ <mr-edit-metadata
+ id="metadataForm"
+ class="edit-actions-right"
+ .formName=${this.phaseName}
+ .fieldDefs=${this.fieldDefs}
+ .phaseName=${this.phaseName}
+ ?disabled=${this._updatingIssue}
+ .error=${this._updateIssueError && this._updateIssueError.description}
+ @save=${this.save}
+ @discard=${this.cancel}
+ isApproval
+ disableAttachments
+ ></mr-edit-metadata>
+ </chops-dialog>
+ `;
+ }
+
+ /**
+ *
+ * @param {FieldDef} field The field to be rendered.
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderPhaseField(field) {
+ const values = valuesForField(this._fieldValueMap, field.fieldRef.fieldName,
+ this.phaseName);
+ return html`
+ <div class="chip">
+ ${field.fieldRef.fieldName}:
+ <mr-field-values
+ .name=${field.fieldRef.fieldName}
+ .type=${field.fieldRef.type}
+ .values=${values}
+ .projectName=${this.issueRef.projectName}
+ ></mr-field-values>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issue: {type: Object},
+ issueRef: {type: Object},
+ phaseName: {type: String},
+ approvals: {type: Array},
+ fieldDefs: {type: Array},
+
+ _updatingIssue: {type: Boolean},
+ _updateIssueError: {type: Object},
+ _fieldValueMap: {type: Object},
+ _milestoneData: {type: Object},
+ _isFetchingMilestone: {type: Boolean},
+ _fetchedMilestone: {type: String},
+ };
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.issue = {};
+ this.issueRef = {};
+ this.phaseName = '';
+ this.approvals = [];
+ this.fieldDefs = [];
+
+ this._updatingIssue = false;
+ this._updateIssueError = undefined;
+
+ // A response Object from
+ // https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+ this._milestoneData = {};
+ this._isFetchingMilestone = false;
+ this._fetchedMilestone = undefined;
+ /**
+ * @type {Promise} Used for testing to allow waiting for milestone
+ * fetch operations to finish.
+ */
+ this._fetchMilestoneComplete = undefined;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.fieldDefs = projectV0.fieldDefsForPhases(state);
+ this._updatingIssue = issueV0.requests(state).update.requesting;
+ this._updateIssueError = issueV0.requests(state).update.error;
+ this._fieldValueMap = issueV0.fieldValueMap(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('issue')) {
+ this.reset();
+ }
+ if (changedProperties.has('_updatingIssue')) {
+ if (!this._updatingIssue && !this._updateIssueError) {
+ // Close phase edit modal only after a request finishes without errors.
+ this.cancel();
+ }
+ }
+
+ if (!this._isFetchingMilestone) {
+ const milestoneToFetch = this._milestoneToFetch;
+ if (milestoneToFetch && this._fetchedMilestone !== milestoneToFetch) {
+ this._fetchMilestoneComplete = this.fetchMilestoneData(
+ milestoneToFetch);
+ }
+ }
+ }
+
+ /**
+ * Makes an XHR request to Chromium Dash to find Chrome-specific launch data.
+ * eg. when certain Chrome milestones are planned for release.
+ * @param {string} milestone A string containing a Chrome milestone number.
+ * @return {Promise<void>}
+ */
+ async fetchMilestoneData(milestone) {
+ this._isFetchingMilestone = true;
+
+ try {
+ const resp = await window.fetch(
+ `https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${
+ milestone}`);
+ this._milestoneData = await resp.json();
+ } catch (error) {
+ console.error(`Error when fetching milestone data: ${error}`);
+ }
+ this._fetchedMilestone = milestone;
+ this._isFetchingMilestone = false;
+ }
+
+ /**
+ * Opens the phase editing dialog when the user clicks the edit button.
+ */
+ edit() {
+ this.reset();
+ this.querySelector('#editPhase').open();
+ }
+
+ /**
+ * Stops editing the phase.
+ */
+ cancel() {
+ this.querySelector('#editPhase').close();
+ this.reset();
+ }
+
+ /**
+ * Resets the edit form to its default values.
+ */
+ reset() {
+ const form = this.querySelector('#metadataForm');
+ form.reset();
+ }
+
+ /**
+ * Saves the changes the user has made.
+ */
+ save() {
+ const form = this.querySelector('#metadataForm');
+ const delta = form.delta;
+
+ if (delta.fieldValsAdd) {
+ delta.fieldValsAdd = delta.fieldValsAdd.map(
+ (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+ }
+ if (delta.fieldValsRemove) {
+ delta.fieldValsRemove = delta.fieldValsRemove.map(
+ (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+ }
+
+ const message = {
+ issueRef: this.issueRef,
+ delta: delta,
+ sendEmail: form.sendEmail,
+ commentContent: form.getCommentContent(),
+ };
+
+ if (message.commentContent || message.delta) {
+ store.dispatch(issueV0.update(message));
+ }
+ }
+
+ /**
+ * Shows the next relevant Chrome Milestone date for this phase. Depending
+ * on the M-Target, M-Approved, or M-Launched values, this date means
+ * different things.
+ * @return {number} Unix timestamp in seconds.
+ * @private
+ */
+ get _nextDate() {
+ const phaseName = this.phaseName;
+ const status = this._status;
+ let data = this._milestoneData && this._milestoneData.mstones;
+ // Data pulled from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+ if (!phaseName || !status || !data || !data.length) return 0;
+ data = data[0];
+
+ let key = TARGET_PHASE_MILESTONE_MAP[phaseName];
+ if (['Approved', 'Launched'].includes(status)) {
+ const osValues = this._fieldValueMap.get('OS');
+ // If iOS is the only OS and the phase is one where iOS has unique
+ // milestones, the only date we show should be this._nextUniqueiOSDate.
+ if (osValues && osValues.every((os) => {
+ return os === 'iOS';
+ }) && phaseName in IOS_APPROVED_PHASE_MILESTONE_MAP) {
+ return 0;
+ }
+ key = APPROVED_PHASE_MILESTONE_MAP[phaseName];
+ }
+ if (!key || !(key in data)) return 0;
+ return Math.floor((new Date(data[key])).getTime() / 1000);
+ }
+
+ /**
+ * For issues where iOS is the OS, this function finds the relevant iOS
+ * launch date.
+ * @return {number} Unix timestamp in seconds.
+ * @private
+ */
+ get _nextUniqueiOSDate() {
+ const phaseName = this.phaseName;
+ const status = this._status;
+ let data = this._milestoneData && this._milestoneData.mstones;
+ // Data pull from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+ if (!phaseName || !status || !data || !data.length) return 0;
+ data = data[0];
+
+ const osValues = this._fieldValueMap.get('OS');
+ if (['Approved', 'Launched'].includes(status) &&
+ osValues && osValues.includes('iOS')) {
+ const key = IOS_APPROVED_PHASE_MILESTONE_MAP[phaseName];
+ if (key) {
+ return Math.floor((new Date(data[key])).getTime() / 1000);
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Depending on what kind of date we're showing, we want to include
+ * different text to describe the date.
+ * @return {string}
+ * @private
+ */
+ get _dateDescriptor() {
+ const status = this._status;
+ if (status === 'Approved') {
+ return 'Launching on ';
+ } else if (status === 'Launched') {
+ return 'Launched on ';
+ }
+ return 'Due by ';
+ }
+
+ /**
+ * The Chrome-specific status of a gate, computed from M-Approved,
+ * M-Launched, and M-Target fields.
+ * @return {string}
+ * @private
+ */
+ get _status() {
+ const target = this._targetMilestone;
+ const approved = this._approvedMilestone;
+ const launched = this._launchedMilestone;
+ if (approved >= target) {
+ if (launched >= approved) {
+ return 'Launched';
+ }
+ return 'Approved';
+ }
+ return 'Target';
+ }
+
+ /**
+ * The Chrome Milestone that this phase was approved for.
+ * @return {string}
+ * @private
+ */
+ get _approvedMilestone() {
+ return valueForField(this._fieldValueMap, 'M-Approved', this.phaseName);
+ }
+
+ /**
+ * The Chrome Milestone that this phase was launched on.
+ * @return {string}
+ * @private
+ */
+ get _launchedMilestone() {
+ return valueForField(this._fieldValueMap, 'M-Launched', this.phaseName);
+ }
+
+ /**
+ * The Chrome Milestone that this phase is targeting.
+ * @return {string}
+ * @private
+ */
+ get _targetMilestone() {
+ return valueForField(this._fieldValueMap, 'M-Target', this.phaseName);
+ }
+
+ /**
+ * The Chrome Milestone that's used to decide what date to show the user.
+ * @return {string}
+ * @private
+ */
+ get _milestoneToFetch() {
+ const target = Number.parseInt(this._targetMilestone) || 0;
+ const approved = Number.parseInt(this._approvedMilestone) || 0;
+ const launched = Number.parseInt(this._launchedMilestone) || 0;
+
+ const latestMilestone = Math.max(target, approved, launched);
+ return latestMilestone > 0 ? `${latestMilestone}` : '';
+ }
+}
+
+
+customElements.define('mr-phase', MrPhase);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
new file mode 100644
index 0000000..d55897e
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
@@ -0,0 +1,209 @@
+// 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 {MrPhase} from './mr-phase.js';
+
+
+let element;
+
+describe('mr-phase', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-phase');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrPhase);
+ });
+
+ it('clicking edit button opens edit dialog', async () => {
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ const editDialog = element.querySelector('#editPhase');
+ assert.isFalse(editDialog.opened);
+
+ element.querySelector('.phase-edit').click();
+
+ await element.updateComplete;
+
+ assert.isTrue(editDialog.opened);
+ });
+
+ it('discarding form changes closes dialog', async () => {
+ await element.updateComplete;
+
+ // Open the edit dialog.
+ element.edit();
+ const editDialog = element.querySelector('#editPhase');
+ const editForm = element.querySelector('#metadataForm');
+
+ await element.updateComplete;
+
+ assert.isTrue(editDialog.opened);
+ editForm.discard();
+
+ await element.updateComplete;
+
+ assert.isFalse(editDialog.opened);
+ });
+
+ describe('milestone fetching', () => {
+ beforeEach(() => {
+ sinon.stub(element, 'fetchMilestoneData');
+ });
+
+ it('_launchedMilestone extracts M-Launched for phase', () => {
+ element._fieldValueMap = new Map([['m-launched beta', ['87']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._launchedMilestone, '87');
+ assert.equal(element._approvedMilestone, undefined);
+ assert.equal(element._targetMilestone, undefined);
+ });
+
+ it('_approvedMilestone extracts M-Approved for phase', () => {
+ element._fieldValueMap = new Map([['m-approved beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._launchedMilestone, undefined);
+ assert.equal(element._approvedMilestone, '86');
+ assert.equal(element._targetMilestone, undefined);
+ });
+
+ it('_targetMilestone extracts M-Target for phase', () => {
+ element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._launchedMilestone, undefined);
+ assert.equal(element._approvedMilestone, undefined);
+ assert.equal(element._targetMilestone, '85');
+ });
+
+ it('_milestoneToFetch returns empty when no relevant milestone', () => {
+ element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+ element.phaseName = 'Stable';
+
+ assert.equal(element._milestoneToFetch, '');
+ });
+
+ it('_milestoneToFetch selects highest milestone', () => {
+ element._fieldValueMap = new Map([
+ ['m-target beta', ['84']],
+ ['m-approved beta', ['85']],
+ ['m-launched beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._milestoneToFetch, '86');
+ });
+
+ it('does not fetch when no milestones specified', async () => {
+ element.issue = {projectName: 'chromium', localId: 12};
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchMilestoneData);
+ });
+
+ it('does not fetch when milestone to fetch is unchanged', async () => {
+ element._fetchedMilestone = '86';
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchMilestoneData);
+ });
+
+ it('fetches when milestone found', async () => {
+ element._fetchedMilestone = undefined;
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '86');
+ });
+
+ it('re-fetches when new milestone found', async () => {
+ element._fetchedMilestone = '86';
+ element._fieldValueMap = new Map([
+ ['m-target beta', ['86']],
+ ['m-launched beta', ['87']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '87');
+ });
+
+ it('re-fetches only after last stale fetch finishes', async () => {
+ element._fetchedMilestone = '84';
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+ element._isFetchingMilestone = true;
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchMilestoneData);
+
+ // Previous in flight fetch finishes.
+ element._fetchedMilestone = '85';
+ element._isFetchingMilestone = false;
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '86');
+ });
+ });
+
+ describe('milestone fetching with fake server responses', () => {
+ beforeEach(() => {
+ sinon.stub(window, 'fetch');
+ sinon.spy(element, 'fetchMilestoneData');
+ });
+
+ afterEach(() => {
+ window.fetch.restore();
+ });
+
+ it('does not refetch when server response finishes', async () => {
+ const response = new window.Response('{"mstones": [{"mstone": 86}]}', {
+ status: 200,
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ });
+
+ window.fetch.returns(Promise.resolve(response));
+
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '86');
+
+ assert.isTrue(element._isFetchingMilestone);
+
+ await element._fetchMilestoneComplete;
+
+ assert.deepEqual(element._milestoneData, {'mstones': [{'mstone': 86}]});
+ assert.equal(element._fetchedMilestone, '86');
+ assert.isFalse(element._isFetchingMilestone);
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetchMilestoneData);
+ });
+ });
+});
diff --git a/static_src/elements/issue-entry/mr-issue-entry-page.js b/static_src/elements/issue-entry/mr-issue-entry-page.js
new file mode 100644
index 0000000..b1cc2ef
--- /dev/null
+++ b/static_src/elements/issue-entry/mr-issue-entry-page.js
@@ -0,0 +1,56 @@
+// Copyright 2020 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, css} from 'lit-element';
+
+/**
+ * `<mr-issue-entry-page>`
+ *
+ * This is the main details section for a given issue.
+ *
+ */
+export class MrIssueEntryPage extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ margin: 0;
+ }
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ userDisplayName: {type: String},
+ loginUrl: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /* dependency injection for testing purpose */
+ this._page = page;
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ if (!this.userDisplayName) {
+ this._page(this.loginUrl);
+ }
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <div>SPA issue entry page place holder</div>
+ `;
+ }
+}
+
+customElements.define('mr-issue-entry-page', MrIssueEntryPage);
diff --git a/static_src/elements/issue-entry/mr-issue-entry-page.test.js b/static_src/elements/issue-entry/mr-issue-entry-page.test.js
new file mode 100644
index 0000000..013a3a4
--- /dev/null
+++ b/static_src/elements/issue-entry/mr-issue-entry-page.test.js
@@ -0,0 +1,58 @@
+// Copyright 2020 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 {MrIssueEntryPage} from './mr-issue-entry-page.js';
+
+let element;
+
+describe('mr-issue-entry-page', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-entry-page');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueEntryPage);
+ });
+
+ describe('requires user to be logged in', () => {
+ it('redirects to loginUrl if not logged in', async () => {
+ document.body.removeChild(element);
+ element = document.createElement('mr-issue-entry-page');
+ assert.isUndefined(element.userDisplayName);
+
+ const EXPECTED = 'abc';
+ element.loginUrl = EXPECTED;
+
+ const pageStub = sinon.stub(element, '_page');
+ document.body.appendChild(element);
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(pageStub);
+ sinon.assert.calledWith(pageStub, EXPECTED);
+ });
+
+ it('renders when user is logged in', async () => {
+ document.body.removeChild(element);
+ element = document.createElement('mr-issue-entry-page');
+
+ element.loginUrl = 'abc';
+ element.userDisplayName = 'not_undefined';
+
+ const pageStub = sinon.stub(element, '_page');
+ const renderSpy = sinon.spy(element, 'render');
+ document.body.appendChild(element);
+ await element.updateComplete;
+
+ sinon.assert.notCalled(pageStub);
+ sinon.assert.calledOnce(renderSpy);
+ });
+ });
+});
diff --git a/static_src/elements/issue-list/mr-chart-page/mr-chart-page.js b/static_src/elements/issue-list/mr-chart-page/mr-chart-page.js
new file mode 100644
index 0000000..06ff7a4
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart-page/mr-chart-page.js
@@ -0,0 +1,100 @@
+// 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 projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import '../mr-mode-selector/mr-mode-selector.js';
+import '../mr-chart/mr-chart.js';
+
+/**
+ * <mr-chart-page>
+ *
+ * Chart page view containing mr-mode-selector and mr-chart.
+ * @extends {LitElement}
+ */
+export class MrChartPage extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 0.5em 8px;
+ }
+ h2 {
+ font-size: 1.2em;
+ margin: 0 0 0.5em;
+ }
+ .list-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ width: 100%;
+ padding: 0.5em 0;
+ height: 32px;
+ }
+ .help {
+ padding: 1em;
+ background-color: rgb(227, 242, 253);
+ width: 44em;
+ font-size: 92%;
+ margin: 5px;
+ padding: 6px;
+ border-radius: 6px;
+ }
+ .monospace {
+ font-family: monospace;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <div class="list-controls">
+ <mr-mode-selector
+ .projectName=${this._projectName}
+ .queryParams=${this._queryParams}
+ .value=${'chart'}
+ ></mr-mode-selector>
+ </div>
+ <mr-chart
+ .projectName=${this._projectName}
+ .queryParams=${this._queryParams}
+ ></mr-chart>
+
+ <div>
+ <div class="help">
+ <h2>Supported query parameters:</h2>
+ <span class="monospace">
+ cc, component, hotlist, label, owner, reporter, status
+ </span>
+ <br /><br />
+ <a href="https://bugs.chromium.org/p/monorail/issues/entry?labels=Feature-Charts">
+ Please file feedback here.
+ </a>
+ </div>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ _projectName: {type: String},
+ /** @private {Object} */
+ _queryParams: {type: Object},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._projectName = projectV0.viewedProjectName(state);
+ this._queryParams = sitewide.queryParams(state);
+ }
+};
+customElements.define('mr-chart-page', MrChartPage);
diff --git a/static_src/elements/issue-list/mr-chart/chops-chart.js b/static_src/elements/issue-list/mr-chart/chops-chart.js
new file mode 100644
index 0000000..a74255a
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/chops-chart.js
@@ -0,0 +1,94 @@
+// 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';
+
+/**
+ * `<chops-chart>`
+ *
+ * Web components wrapper around Chart.js.
+ *
+ */
+export class ChopsChart extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <canvas></canvas>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ type: {type: String},
+ data: {type: Object},
+ options: {type: Object},
+ _chart: {type: Object},
+ _chartConstructor: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.type = 'line';
+ this.data = {};
+ this.options = {};
+ }
+
+ /**
+ * Dynamically chartJs to reduce single EZT bundle size
+ * Move to static import once EZT is deprecated
+ */
+ async connectedCallback() {
+ super.connectedCallback();
+ /* eslint-disable max-len */
+ const {default: Chart} = await import(
+ /* webpackChunkName: "chartjs" */ 'chart.js/dist/Chart.bundle.min.js');
+ this._chartConstructor = Chart;
+ }
+
+ /**
+ * Refetch and rerender chart after property changes
+ * @override
+ * @param {Map} changedProperties
+ */
+ updated(changedProperties) {
+ // Make sure chartJS has loaded before attempting to create a chart
+ if (this._chartConstructor) {
+ if (!this._chart) {
+ const {type, data, options} = this;
+ const ctx = this.shadowRoot.querySelector('canvas').getContext('2d');
+ this._chart = new this._chartConstructor(ctx, {type, data, options});
+ } else if (
+ changedProperties.has('type') ||
+ changedProperties.has('data') ||
+ changedProperties.has('options')) {
+ this._updateChart();
+ }
+ }
+ }
+
+ /**
+ * Sets chartJs options and calls update
+ */
+ _updateChart() {
+ this._chart.type = this.type;
+ this._chart.data = this.data;
+ this._chart.options = this.options;
+
+ this._chart.update();
+ }
+}
+
+customElements.define('chops-chart', ChopsChart);
diff --git a/static_src/elements/issue-list/mr-chart/chops-chart.test.js b/static_src/elements/issue-list/mr-chart/chops-chart.test.js
new file mode 100644
index 0000000..bf05012
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/chops-chart.test.js
@@ -0,0 +1,24 @@
+// 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 {ChopsChart} from './chops-chart.js';
+
+
+let element;
+
+describe('chops-chart', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-chart');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsChart);
+ });
+});
diff --git a/static_src/elements/issue-list/mr-chart/mr-chart.js b/static_src/elements/issue-list/mr-chart/mr-chart.js
new file mode 100644
index 0000000..a4c4189
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/mr-chart.js
@@ -0,0 +1,1041 @@
+// 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 page from 'page';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {linearRegression} from 'shared/math.js';
+import './chops-chart.js';
+import {urlWithNewParams, createObjectComparisonFunc} from 'shared/helpers.js';
+
+const DEFAULT_NUM_DAYS = 90;
+const SECONDS_IN_DAY = 24 * 60 * 60;
+const MAX_QUERY_SIZE = 90;
+const MAX_DISPLAY_LINES = 10;
+const predRangeType = Object.freeze({
+ NEXT_MONTH: 0,
+ NEXT_QUARTER: 1,
+ NEXT_50: 2,
+ HIDE: 3,
+});
+const CHART_OPTIONS = {
+ animation: false,
+ responsive: true,
+ title: {
+ display: true,
+ text: 'Issues over time',
+ },
+ tooltips: {
+ mode: 'x',
+ intersect: false,
+ },
+ hover: {
+ mode: 'x',
+ intersect: false,
+ },
+ legend: {
+ display: true,
+ labels: {
+ boxWidth: 15,
+ },
+ },
+ scales: {
+ xAxes: [{
+ display: true,
+ type: 'time',
+ time: {parser: 'MM/DD/YYYY', tooltipFormat: 'll'},
+ scaleLabel: {
+ display: true,
+ labelString: 'Day',
+ },
+ }],
+ yAxes: [{
+ display: true,
+ ticks: {
+ beginAtZero: true,
+ },
+ scaleLabel: {
+ display: true,
+ labelString: 'Value',
+ },
+ }],
+ },
+};
+const COLOR_CHOICES = ['#00838F', '#B71C1C', '#2E7D32', '#00659C',
+ '#5D4037', '#558B2F', '#FF6F00', '#6A1B9A', '#880E4F', '#827717'];
+const BG_COLOR_CHOICES = ['#B2EBF2', '#EF9A9A', '#C8E6C9', '#B2DFDB',
+ '#D7CCC8', '#DCEDC8', '#FFECB3', '#E1BEE7', '#F8BBD0', '#E6EE9C'];
+
+/**
+ * Set of serialized state this element should update for.
+ * mr-app lowercases all query parameters before putting into store.
+ * @type {Set<string>}
+ */
+export const subscribedQuery = new Set([
+ 'start-date',
+ 'end-date',
+ 'groupby',
+ 'labelprefix',
+ 'q',
+ 'can',
+]);
+
+const queryParamsHaveChanged = createObjectComparisonFunc(subscribedQuery);
+
+/**
+ * Mapping between query param's groupby value and chart application data.
+ * @type {Object}
+ */
+const groupByMapping = {
+ 'open': {display: 'Is open', value: 'open'},
+ 'owner': {display: 'Owner', value: 'owner'},
+ 'comonent': {display: 'Component', value: 'component'},
+ 'status': {display: 'Status', value: 'status'},
+};
+
+/**
+ * `<mr-chart>`
+ *
+ * Component rendering the chart view
+ *
+ */
+export default class MrChart extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ max-width: 800px;
+ margin: 0 auto;
+ }
+ chops-chart {
+ max-width: 100%;
+ }
+ div#options {
+ max-width: 720px;
+ margin: 2em auto;
+ text-align: center;
+ }
+ div#options #unsupported-fields {
+ font-weight: bold;
+ color: orange;
+ }
+ div.align {
+ display: flex;
+ }
+ div.align #frequency, div.align #groupBy {
+ display: inline-block;
+ width: 40%;
+ }
+ div.align #frequency #two-toggle {
+ font-size: 95%;
+ text-align: center;
+ margin-bottom: 5px;
+ }
+ div.align #time, div.align #prediction {
+ display: inline-block;
+ width: 60%;
+ }
+ #dropdown {
+ height: 50%;
+ }
+ div.section {
+ display: inline-block;
+ text-align: center;
+ }
+ div.section.input {
+ padding: 4px 10px;
+ }
+ .menu {
+ min-width: 50%;
+ text-align: left;
+ font-size: 12px;
+ box-sizing: border-box;
+ text-decoration: none;
+ white-space: nowrap;
+ padding: 0.25em 8px;
+ transition: 0.2s background ease-in-out;
+ cursor: pointer;
+ color: var(--chops-link-color);
+ }
+ .menu:hover {
+ background: hsl(0, 0%, 90%);
+ }
+ .choice.transparent {
+ background: var(--chops-white);
+ border-color: var(--chops-choice-color);
+ border-radius: 4px;
+ }
+ .choice.shown {
+ background: var(--chops-active-choice-bg);
+ }
+ .choice {
+ padding: 4px 10px;
+ background: var(--chops-choice-bg);
+ color: var(--chops-choice-color);
+ text-decoration: none;
+ display: inline-block;
+ }
+ .choice.checked {
+ background: var(--chops-active-choice-bg);
+ }
+ p .warning-message {
+ display: none;
+ font-size: 1.25em;
+ padding: 0.25em;
+ background-color: var(--chops-orange-50);
+ }
+ progress {
+ background-color: var(--chops-white);
+ border: 1px solid var(--chops-gray-500);
+ margin: 0 0 1em;
+ width: 100%;
+ visibility: visible;
+ }
+ ::-webkit-progress-bar {
+ background-color: var(--chops-white);
+ }
+ progress::-webkit-progress-value {
+ transition: width 1s;
+ background-color: #00838F;
+ }
+ `;
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('queryParams')) {
+ this._setPropsFromQueryParams();
+ this._fetchData();
+ }
+ }
+
+ /** @override */
+ render() {
+ const doneLoading = this.progress === 1;
+ return html`
+ <chops-chart
+ type="line"
+ .options=${CHART_OPTIONS}
+ .data=${this._chartData(this.indices, this.values)}
+ ></chops-chart>
+ <div id="options">
+ <p id="unsupported-fields">
+ ${this.unsupportedFields.length ? `
+ Unsupported fields: ${this.unsupportedFields.join(', ')}`: ''}
+ </p>
+ <progress
+ value=${this.progress}
+ ?hidden=${doneLoading}
+ >Loading chart...</progress>
+ <p class="warning-message" ?hidden=${!this.searchLimitReached}>
+ Note: Some results are not being counted.
+ Please narrow your query.
+ </p>
+ <p class="warning-message" ?hidden=${!this.maxQuerySizeReached}>
+ Your query is too long.
+ Showing ${MAX_QUERY_SIZE} weeks from end date.
+ </p>
+ <p class="warning-message" ?hidden=${!this.dateRangeNotLegal}>
+ Your requested date range does not exist.
+ Showing ${MAX_QUERY_SIZE} days from end date.
+ </p>
+ <p class="warning-message" ?hidden=${!this.cannedQueryOpen}>
+ Your query scope prevents closed issues from showing.
+ </p>
+ <div class="align">
+ <div id="frequency">
+ <label for="two-toggle">Choose date range:</label>
+ <div id="two-toggle">
+ <chops-button @click="${this._setDateRange.bind(this, 180)}"
+ class="${this.dateRange === 180 ? 'choice checked': 'choice'}">
+ 180 Days
+ </chops-button>
+ <chops-button @click="${this._setDateRange.bind(this, 90)}"
+ class="${this.dateRange === 90 ? 'choice checked': 'choice'}">
+ 90 Days
+ </chops-button>
+ <chops-button @click="${this._setDateRange.bind(this, 30)}"
+ class="${this.dateRange === 30 ? 'choice checked': 'choice'}">
+ 30 Days
+ </chops-button>
+ </div>
+ </div>
+ <div id="time">
+ <label for="start-date">Choose start and end date:</label>
+ <br />
+ <input
+ type="date"
+ id="start-date"
+ name="start-date"
+ .value=${this.startDate && this.startDate.toISOString().substr(0, 10)}
+ ?disabled=${!doneLoading}
+ @change=${(e) => this.startDate = MrChart.dateStringToDate(e.target.value)}
+ />
+ <input
+ type="date"
+ id="end-date"
+ name="end-date"
+ .value=${this.endDate && this.endDate.toISOString().substr(0, 10)}
+ ?disabled=${!doneLoading}
+ @change=${(e) => this.endDate = MrChart.dateStringToDate(e.target.value)}
+ />
+ <chops-button @click="${this._onDateChanged}" class=choice>
+ Apply
+ </chops-button>
+ </div>
+ </div>
+ <div class="align">
+ <div id="prediction">
+ <label for="two-toggle">Choose prediction range:</label>
+ <div id="two-toggle">
+ ${this._renderPredictChoice('Future Month', predRangeType.NEXT_MONTH)}
+ ${this._renderPredictChoice('Future Quarter', predRangeType.NEXT_QUARTER)}
+ ${this._renderPredictChoice('Future 50%', predRangeType.NEXT_50)}
+ ${this._renderPredictChoice('Hide', predRangeType.HIDE)}
+ </div>
+ </div>
+ <div id="groupBy">
+ <label for="dropdown">Choose group by:</label>
+ <mr-dropdown
+ id="dropdown"
+ ?disabled=${!doneLoading}
+ .text=${this.groupBy.display}
+ >
+ ${this.dropdownHTML}
+ </mr-dropdown>
+ </div>
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Renders a single prediction button.
+ * @param {string} choiceName The text displayed on the button.
+ * @param {number} rangeType An enum-like number specifying which range
+ * to use.
+ * @return {TemplateResult}
+ */
+ _renderPredictChoice(choiceName, rangeType) {
+ const changePrediction = (_e) => {
+ this.predRange = rangeType;
+ this._fetchData();
+ };
+ return html`
+ <chops-button
+ @click=${changePrediction}
+ class="${this.predRange === rangeType ? 'checked': ''} choice">
+ ${choiceName}
+ </chops-button>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ progress: {type: Number},
+ projectName: {type: String},
+ hotlistId: {type: Number},
+ indices: {type: Array},
+ values: {type: Array},
+ unsupportedFields: {type: Array},
+ dateRangeNotLegal: {type: Boolean},
+ dateRange: {type: Number},
+ frequency: {type: Number},
+ queryParams: {
+ type: Object,
+ hasChanged: queryParamsHaveChanged,
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.progress = 0.05;
+ this.values = [];
+ this.indices = [];
+ this.unsupportedFields = [];
+ this.predRange = predRangeType.HIDE;
+ this._page = page;
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ if (!this.projectName && !this.hotlistId) {
+ throw new Error('Attribute `projectName` or `hotlistId` required.');
+ }
+ this._setPropsFromQueryParams();
+ this._constructDropdownMenu();
+ }
+
+ /**
+ * Initialize queryParams and set properties from the queryParams.
+ * Since this page exists in both the SPA and ezt they initialize mr-chart
+ * differently, ie in ezt, this.queryParams will be undefined during
+ * connectedCallback. Until ezt is deleted, initialize props here.
+ */
+ _setPropsFromQueryParams() {
+ if (!this.queryParams) {
+ const params = qs.parse(document.location.search.substring(1));
+ // ezt pages used querystring as source of truth
+ // and 'labelPrefix'in query param, but SPA uses
+ // redux store's sitewide.queryParams as source of truth
+ // and lowercases all keys in sitewide.queryParams
+ if (params.hasOwnProperty('labelPrefix')) {
+ const labelPrefixValue = params['labelPrefix'];
+ params['labelprefix'] = labelPrefixValue;
+ delete params['labelPrefix'];
+ }
+ this.queryParams = params;
+ }
+ this.endDate = MrChart.getEndDate(this.queryParams['end-date']);
+ this.startDate = MrChart.getStartDate(
+ this.queryParams['start-date'],
+ this.endDate, DEFAULT_NUM_DAYS);
+ this.groupBy = MrChart.getGroupByFromQuery(this.queryParams);
+ }
+
+ /**
+ * Set dropdown options menu in HTML.
+ */
+ async _constructDropdownMenu() {
+ const response = await this._getLabelPrefixes();
+ let dropdownOptions = ['None', 'Component', 'Is open', 'Status', 'Owner'];
+ dropdownOptions = dropdownOptions.concat(response);
+ const dropdownHTML = dropdownOptions.map((str) => html`
+ <option class='menu' @click=${this._setGroupBy}>
+ ${str}</option>`);
+ this.dropdownHTML = html`${dropdownHTML}`;
+ }
+
+ /**
+ * Call global page.js to change frontend route based on new parameters
+ * @param {Object<string, string>} newParams
+ */
+ _changeUrlParams(newParams) {
+ const newUrl = urlWithNewParams(`/p/${this.projectName}/issues/list`,
+ this.queryParams, newParams);
+ this._page(newUrl);
+ }
+
+ /**
+ * Set start date and end date and trigger url action
+ */
+ _onDateChanged() {
+ const newParams = {
+ 'start-date': this.startDate.toISOString().substr(0, 10),
+ 'end-date': this.endDate.toISOString().substr(0, 10),
+ };
+ this._changeUrlParams(newParams);
+ }
+
+ /**
+ * Fetch data required to render chart
+ * @fires Event#allDataLoaded
+ */
+ async _fetchData() {
+ this.dateRange = Math.ceil(
+ (this.endDate - this.startDate) / (1000 * SECONDS_IN_DAY));
+
+ // Coordinate different params and flags, protect against illegal queries
+ // Case for start date greater than end date.
+ if (this.dateRange <= 0) {
+ this.frequency = 7;
+ this.dateRangeNotLegal = true;
+ this.maxQuerySizeReached = false;
+ this.dateRange = MAX_QUERY_SIZE;
+ } else {
+ this.dateRangeNotLegal = false;
+ if (this.dateRange >= MAX_QUERY_SIZE * 7) {
+ // Case for date range too long, requires >= MAX_QUERY_SIZE queries.
+ this.frequency = 7;
+ this.maxQuerySizeReached = true;
+ this.dateRange = MAX_QUERY_SIZE * 7;
+ } else {
+ this.maxQuerySizeReached = false;
+ if (this.dateRange < MAX_QUERY_SIZE) {
+ // Case for small date range, displayed in daily frequency.
+ this.frequency = 1;
+ } else {
+ // Case for medium date range, displayed in weekly frequency.
+ this.frequency = 7;
+ }
+ }
+ }
+ // Set canned query flag.
+ this.cannedQueryOpen = (this.queryParams.can === '2' &&
+ this.groupBy.value === 'open');
+
+ // Reset chart variables except indices.
+ this.progress = 0.05;
+
+ let numTimestampsLoaded = 0;
+ const timestampsChronological = MrChart.makeTimestamps(this.endDate,
+ this.frequency, this.dateRange);
+ const tsToIndexMap = new Map(timestampsChronological.map((ts, idx) => (
+ [ts, idx]
+ )));
+ this.indices = MrChart.makeIndices(timestampsChronological);
+ const timestamps = MrChart.sortInBisectOrder(timestampsChronological);
+ this.values = new Array(timestamps.length).fill(undefined);
+
+ const fetchPromises = timestamps.map(async (ts) => {
+ const data = await this._fetchDataAtTimestamp(ts);
+ const index = tsToIndexMap.get(ts);
+ this.values[index] = data.issues;
+ numTimestampsLoaded += 1;
+ const progressValue = numTimestampsLoaded / timestamps.length;
+ this.progress = progressValue;
+
+ return data;
+ });
+
+ const chartData = await Promise.all(fetchPromises);
+
+ // This is purely for testing purposes
+ this.dispatchEvent(new Event('allDataLoaded'));
+
+ // Check if the query includes any field values that are not supported.
+ const flatUnsupportedFields = chartData.reduce((acc, datum) => {
+ if (datum.unsupportedField) {
+ acc = acc.concat(datum.unsupportedField);
+ }
+ return acc;
+ }, []);
+ this.unsupportedFields = Array.from(new Set(flatUnsupportedFields));
+
+ this.searchLimitReached = chartData.some((d) => d.searchLimitReached);
+ }
+
+ /**
+ * fetch data at timestamp
+ * @param {number} timestamp
+ * @return {{date: number, issues: Array<Map.<string, number>>,
+ * unsupportedField: string, searchLimitReached: string}}
+ */
+ async _fetchDataAtTimestamp(timestamp) {
+ const query = this.queryParams.q;
+ const cannedQuery = this.queryParams.can;
+ const message = {
+ timestamp: timestamp,
+ projectName: this.projectName,
+ query: query,
+ cannedQuery: cannedQuery,
+ hotlistId: this.hotlistId,
+ groupBy: undefined,
+ };
+ if (this.groupBy.value !== '') {
+ message['groupBy'] = this.groupBy.value;
+ if (this.groupBy.value === 'label') {
+ message['labelPrefix'] = this.groupBy.labelPrefix;
+ }
+ }
+ const response = await prpcClient.call('monorail.Issues',
+ 'IssueSnapshot', message);
+
+ let issues;
+ if (response.snapshotCount) {
+ issues = response.snapshotCount.reduce((map, curr) => {
+ if (curr.dimension !== undefined) {
+ if (this.groupBy.value === '') {
+ map.set('Issue Count', curr.count);
+ } else {
+ map.set(curr.dimension, curr.count);
+ }
+ }
+ return map;
+ }, new Map());
+ } else {
+ issues = new Map();
+ }
+ return {
+ date: timestamp * 1000,
+ issues: issues,
+ unsupportedField: response.unsupportedField,
+ searchLimitReached: response.searchLimitReached,
+ };
+ }
+
+ /**
+ * Get prefixes from the set of labels.
+ */
+ async _getLabelPrefixes() {
+ // If no project (i.e. viewing a hotlist), return empty list.
+ if (!this.projectName) {
+ return [];
+ }
+
+ const projectRequestMessage = {
+ project_name: this.projectName};
+ const labelsResponse = await prpcClient.call(
+ 'monorail.Projects', 'GetLabelOptions', projectRequestMessage);
+ const labelPrefixes = new Set();
+ for (let i = 0; i < labelsResponse.labelOptions.length; i++) {
+ const label = labelsResponse.labelOptions[i].label;
+ if (label.includes('-')) {
+ labelPrefixes.add(label.split('-')[0]);
+ }
+ }
+ return Array.from(labelPrefixes);
+ }
+
+ /**
+ * construct chart data
+ * @param {Array} indices
+ * @param {Array} values
+ * @return {Object} chart data and options
+ */
+ _chartData(indices, values) {
+ // Generate a map of each data line {dimension:string, value:array}
+ const mapValues = new Map();
+ for (let i = 0; i < values.length; i++) {
+ if (values[i] !== undefined) {
+ values[i].forEach((value, key, map) => mapValues.set(key, []));
+ }
+ }
+ // Count the number of 0 or undefined data points.
+ let count = 0;
+ for (let i = 0; i < values.length; i++) {
+ if (values[i] !== undefined) {
+ if (values[i].size === 0) {
+ count++;
+ }
+ // Set none-existing data points 0.
+ mapValues.forEach((value, key, map) => {
+ mapValues.set(key, value.concat([values[i].get(key) || 0]));
+ });
+ } else {
+ count++;
+ }
+ }
+ // Legend display set back to default.
+ CHART_OPTIONS.legend.display = true;
+ // Check if any positive valued data exist, if not, draw an array of zeros.
+ if (count === values.length) {
+ return {
+ type: 'line',
+ labels: indices,
+ datasets: [{
+ label: this.groupBy.labelPrefix,
+ data: Array(indices.length).fill(0),
+ backgroundColor: COLOR_CHOICES[0],
+ borderColor: COLOR_CHOICES[0],
+ showLine: true,
+ fill: false,
+ }],
+ };
+ }
+ // Convert map to a dataset of lines.
+ let arrayValues = [];
+ mapValues.forEach((value, key, map) => {
+ arrayValues.push({
+ label: key,
+ data: value,
+ backgroundColor: COLOR_CHOICES[arrayValues.length %
+ COLOR_CHOICES.length],
+ borderColor: COLOR_CHOICES[arrayValues.length % COLOR_CHOICES.length],
+ showLine: true,
+ fill: false,
+ });
+ });
+ arrayValues = MrChart.getSortedLines(arrayValues, MAX_DISPLAY_LINES);
+ if (this.predRange === predRangeType.HIDE) {
+ return {
+ type: 'line',
+ labels: indices,
+ datasets: arrayValues,
+ };
+ }
+
+ let predictedValues = [];
+ let originalData;
+ let predictedData;
+ let maxData;
+ let minData;
+ let currColor;
+ let currBGColor;
+ // Check if displayed values > MAX_DISPLAY_LINES, hide legend.
+ if (arrayValues.length * 4 > MAX_DISPLAY_LINES) {
+ CHART_OPTIONS.legend.display = false;
+ } else {
+ CHART_OPTIONS.legend.display = true;
+ }
+ for (let i = 0; i < arrayValues.length; i++) {
+ [originalData, predictedData, maxData, minData] =
+ MrChart.getAllData(indices, arrayValues[i]['data'], this.dateRange,
+ this.predRange, this.frequency, this.endDate);
+ currColor = COLOR_CHOICES[i % COLOR_CHOICES.length];
+ currBGColor = BG_COLOR_CHOICES[i % COLOR_CHOICES.length];
+ predictedValues = predictedValues.concat([{
+ label: arrayValues[i]['label'],
+ backgroundColor: currColor,
+ borderColor: currColor,
+ data: originalData,
+ showLine: true,
+ fill: false,
+ }, {
+ label: arrayValues[i]['label'].concat(' prediction'),
+ backgroundColor: currColor,
+ borderColor: currColor,
+ borderDash: [5, 5],
+ data: predictedData,
+ pointRadius: 0,
+ showLine: true,
+ fill: false,
+ }, {
+ label: arrayValues[i]['label'].concat(' lower error'),
+ backgroundColor: currBGColor,
+ borderColor: currBGColor,
+ borderDash: [5, 5],
+ data: minData,
+ pointRadius: 0,
+ showLine: true,
+ hidden: true,
+ fill: false,
+ }, {
+ label: arrayValues[i]['label'].concat(' upper error'),
+ backgroundColor: currBGColor,
+ borderColor: currBGColor,
+ borderDash: [5, 5],
+ data: maxData,
+ pointRadius: 0,
+ showLine: true,
+ hidden: true,
+ fill: '-1',
+ }]);
+ }
+ return {
+ type: 'scatter',
+ datasets: predictedValues,
+ };
+ }
+
+ /**
+ * Change group by based on dropdown menu selection.
+ * @param {Event} e
+ */
+ _setGroupBy(e) {
+ switch (e.target.text) {
+ case 'None':
+ this.groupBy = {value: undefined};
+ break;
+ case 'Is open':
+ this.groupBy = {value: 'open'};
+ break;
+ case 'Owner':
+ case 'Component':
+ case 'Status':
+ this.groupBy = {value: e.target.text.toLowerCase()};
+ break;
+ default:
+ this.groupBy = {value: 'label', labelPrefix: e.target.text};
+ }
+ this.groupBy['display'] = e.target.text;
+ this.shadowRoot.querySelector('#dropdown').text = e.target.text;
+ this.shadowRoot.querySelector('#dropdown').close();
+
+ const newParams = {
+ 'groupby': this.groupBy.value,
+ 'labelprefix': this.groupBy.labelPrefix,
+ };
+
+ this._changeUrlParams(newParams);
+ }
+
+ /**
+ * Change date range and frequency based on button clicked.
+ * @param {number} dateRange Number of days in date range
+ */
+ _setDateRange(dateRange) {
+ if (this.dateRange !== dateRange) {
+ this.startDate = new Date(
+ this.endDate.getTime() - 1000 * SECONDS_IN_DAY * dateRange);
+ this._onDateChanged();
+ window.getTSMonClient().recordDateRangeChange(dateRange);
+ }
+ }
+
+ /**
+ * Move first, last, and median to the beginning of the array, recursively.
+ * @param {Array} timestamps
+ * @return {Array}
+ */
+ static sortInBisectOrder(timestamps) {
+ const arr = [];
+ if (timestamps.length === 0) {
+ return arr;
+ } else if (timestamps.length <= 2) {
+ return timestamps;
+ } else {
+ const beginTs = timestamps.shift();
+ const endTs = timestamps.pop();
+ const medianTs = timestamps.splice(timestamps.length / 2, 1)[0];
+ return [beginTs, endTs, medianTs].concat(
+ MrChart.sortInBisectOrder(timestamps));
+ }
+ }
+
+ /**
+ * Populate array of timestamps we want to fetch.
+ * @param {Date} endDate
+ * @param {number} frequency
+ * @param {number} numDays
+ * @return {Array}
+ */
+ static makeTimestamps(endDate, frequency, numDays=DEFAULT_NUM_DAYS) {
+ if (!endDate) {
+ throw new Error('endDate required');
+ }
+ const endTimeSeconds = Math.round(endDate.getTime() / 1000);
+ const timestampsChronological = [];
+ for (let i = 0; i < numDays; i += frequency) {
+ timestampsChronological.unshift(endTimeSeconds - (SECONDS_IN_DAY * i));
+ }
+ return timestampsChronological;
+ }
+
+ /**
+ * Convert a string '2018-11-03' to a Date object.
+ * @param {string} dateString
+ * @return {Date}
+ */
+ static dateStringToDate(dateString) {
+ if (!dateString) {
+ return null;
+ }
+ const splitDate = dateString.split('-');
+ const year = Number.parseInt(splitDate[0]);
+ // Month is 0-indexed, so subtract one.
+ const month = Number.parseInt(splitDate[1]) - 1;
+ const day = Number.parseInt(splitDate[2]);
+ return new Date(Date.UTC(year, month, day, 23, 59, 59));
+ }
+
+ /**
+ * Returns a Date parsed from string input, defaults to current date.
+ * @param {string} input
+ * @return {Date}
+ */
+ static getEndDate(input) {
+ if (input) {
+ const date = MrChart.dateStringToDate(input);
+ if (date) {
+ return date;
+ }
+ }
+ const today = new Date();
+ today.setHours(23);
+ today.setMinutes(59);
+ today.setSeconds(59);
+ return today;
+ }
+
+ /**
+ * Return a Date parsed from string input
+ * defaults to diff days befores endDate
+ * @param {string} input
+ * @param {Date} endDate
+ * @param {number} diff
+ * @return {Date}
+ */
+ static getStartDate(input, endDate, diff) {
+ if (input) {
+ const date = MrChart.dateStringToDate(input);
+ if (date) {
+ return date;
+ }
+ }
+ return new Date(endDate.getTime() - 1000 * SECONDS_IN_DAY * diff);
+ }
+
+ /**
+ * Make indices
+ * @param {Array} timestamps
+ * @return {Array}
+ */
+ static makeIndices(timestamps) {
+ const dateFormat = {year: 'numeric', month: 'numeric', day: 'numeric'};
+ return timestamps.map((ts) => (
+ (new Date(ts * 1000)).toLocaleDateString('en-US', dateFormat)
+ ));
+ }
+
+ /**
+ * Generate predicted future data based on previous data.
+ * @param {Array} values
+ * @param {number} dateRange
+ * @param {number} interval
+ * @param {number} frequency
+ * @param {Date} inputEndDate
+ * @return {Array}
+ */
+ static getPredictedData(
+ values, dateRange, interval, frequency, inputEndDate) {
+ // TODO(weihanl): changes to support frequencies other than 1 and 7.
+ let n;
+ let endDateRange;
+ if (frequency === 1) {
+ // Display in daily.
+ n = values.length;
+ endDateRange = interval;
+ } else {
+ // Display in weekly.
+ n = Math.floor((DEFAULT_NUM_DAYS + 1) / 7);
+ endDateRange = interval * 7 - 1;
+ }
+ const [slope, intercept] = linearRegression(values, n);
+ const endDate = new Date(inputEndDate.getTime() +
+ 1000 * SECONDS_IN_DAY * (1 + endDateRange));
+ const timestampsChronological = MrChart.makeTimestamps(
+ endDate, frequency, endDateRange);
+ const predictedIndices = MrChart.makeIndices(timestampsChronological);
+
+ // Obtain future data and past data on the generated line.
+ const predictedValues = [];
+ const generatedValues = [];
+ for (let i = 0; i < interval; i++) {
+ predictedValues.push(Math.round(100*((i + n) * slope + intercept)) / 100);
+ }
+ for (let i = 0; i < n; i++) {
+ generatedValues.push(Math.round(100*(i * slope + intercept)) / 100);
+ }
+ return [predictedIndices, predictedValues, generatedValues];
+ }
+
+ /**
+ * Generate error range lines using +/- standard error
+ * on intercept to original line.
+ * @param {Array} generatedValues
+ * @param {Array} values
+ * @param {Array} predictedValues
+ * @return {Array}
+ */
+ static getErrorData(generatedValues, values, predictedValues) {
+ const diffs = [];
+ for (let i = 0; i < generatedValues.length; i++) {
+ diffs.push(values[values.length - generatedValues.length + i] -
+ generatedValues[i]);
+ }
+ const sqDiffs = diffs.map((v) => v * v);
+ const stdDev = sqDiffs.reduce((sum, v) => sum + v) / values.length;
+ const maxValues = predictedValues.map(
+ (x) => Math.round(100 * (x + stdDev)) / 100);
+ const minValues = predictedValues.map(
+ (x) => Math.round(100 * (x - stdDev)) / 100);
+ return [maxValues, minValues];
+ }
+
+ /**
+ * Format all data using scattered dot representation for a single chart line.
+ * @param {Array} indices
+ * @param {Array} values
+ * @param {humber} dateRange
+ * @param {number} predRange
+ * @param {number} frequency
+ * @param {Date} endDate
+ * @return {Array}
+ */
+ static getAllData(indices, values, dateRange, predRange, frequency, endDate) {
+ // Set the number of data points that needs to be generated based on
+ // future time range and frequency.
+ let interval;
+ switch (predRange) {
+ case predRangeType.NEXT_MONTH:
+ interval = frequency === 1 ? 30 : 4;
+ break;
+ case predRangeType.NEXT_QUARTER:
+ interval = frequency === 1 ? 90 : 13;
+ break;
+ case predRangeType.NEXT_50:
+ interval = Math.floor((dateRange + 1) / (frequency * 2));
+ break;
+ }
+
+ const [predictedIndices, predictedValues, generatedValues] =
+ MrChart.getPredictedData(values, dateRange, interval, frequency, endDate);
+ const [maxValues, minValues] =
+ MrChart.getErrorData(generatedValues, values, predictedValues);
+ const n = generatedValues.length;
+
+ // Format data into an array of {x:"MM/DD/YYYY", y:1.00} to draw chart.
+ const originalData = [];
+ const predictedData = [];
+ const maxData = [{
+ x: indices[values.length - 1],
+ y: generatedValues[n - 1],
+ }];
+ const minData = [{
+ x: indices[values.length - 1],
+ y: generatedValues[n - 1],
+ }];
+ for (let i = 0; i < values.length; i++) {
+ originalData.push({x: indices[i], y: values[i]});
+ }
+ for (let i = 0; i < n; i++) {
+ predictedData.push({x: indices[values.length - n + i],
+ y: Math.max(Math.round(100 * generatedValues[i]) / 100, 0)});
+ }
+ for (let i = 0; i < predictedValues.length; i++) {
+ predictedData.push({
+ x: predictedIndices[i],
+ y: Math.max(predictedValues[i], 0),
+ });
+ maxData.push({x: predictedIndices[i], y: Math.max(maxValues[i], 0)});
+ minData.push({x: predictedIndices[i], y: Math.max(minValues[i], 0)});
+ }
+ return [originalData, predictedData, maxData, minData];
+ }
+
+ /**
+ * Sort lines by data in reversed chronological order and
+ * return top n lines with most issues.
+ * @param {Array} arrayValues
+ * @param {number} index
+ * @return {Array}
+ */
+ static getSortedLines(arrayValues, index) {
+ if (index >= arrayValues.length) {
+ return arrayValues;
+ }
+ // Convert data by reversing and starting from last digit and sort
+ // according to the resulting value. e.g. [4,2,0] => 24, [0,4,3] => 340
+ const sortedValues = arrayValues.slice().sort((arrX, arrY) => {
+ const intX = parseInt(
+ arrX.data.map((i) => i.toString()).reverse().join(''));
+ const intY = parseInt(
+ arrY.data.map((i) => i.toString()).reverse().join(''));
+ return intY - intX;
+ });
+ return sortedValues.slice(0, index);
+ }
+
+ /**
+ * Parses queryParams for groupBy property
+ * @param {Object<string, string>} queryParams
+ * @return {Object<string, string>}
+ */
+ static getGroupByFromQuery(queryParams) {
+ const defaultValue = {display: 'None', value: ''};
+
+ const labelMapping = {
+ 'label': {
+ display: queryParams.labelprefix,
+ value: 'label',
+ labelPrefix: queryParams.labelprefix,
+ },
+ };
+
+ return groupByMapping[queryParams.groupby] ||
+ labelMapping[queryParams.groupby] ||
+ defaultValue;
+ }
+}
+
+customElements.define('mr-chart', MrChart);
diff --git a/static_src/elements/issue-list/mr-chart/mr-chart.test.js b/static_src/elements/issue-list/mr-chart/mr-chart.test.js
new file mode 100644
index 0000000..8c079fd
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/mr-chart.test.js
@@ -0,0 +1,524 @@
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import MrChart, {
+ subscribedQuery,
+} from 'elements/issue-list/mr-chart/mr-chart.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let dataLoadedPromise;
+
+const beforeEachElement = () => {
+ if (element && document.body.contains(element)) {
+ // Avoid setting up multiple versions of the same element.
+ document.body.removeChild(element);
+ element = null;
+ }
+ const el = document.createElement('mr-chart');
+ el.setAttribute('projectName', 'rutabaga');
+ dataLoadedPromise = new Promise((resolve) => {
+ el.addEventListener('allDataLoaded', resolve);
+ });
+
+ document.body.appendChild(el);
+ return el;
+};
+
+describe('mr-chart', () => {
+ beforeEach(() => {
+ window.CS_env = {
+ token: 'rutabaga-token',
+ tokenExpiresSec: 0,
+ app_version: 'rutabaga-version',
+ };
+ sinon.stub(prpcClient, 'call').callsFake(async () => {
+ return {
+ snapshotCount: [{count: 8}],
+ unsupportedField: [],
+ searchLimitReached: false,
+ };
+ });
+
+ element = beforeEachElement();
+ });
+
+ afterEach(async () => {
+ // _fetchData is always called when the element is connected, so we have to
+ // wait until all data has been loaded.
+ // Otherwise prpcClient.call will be restored and we will make actual XHR
+ // calls.
+ await dataLoadedPromise;
+
+ document.body.removeChild(element);
+
+ prpcClient.call.restore();
+ });
+
+ describe('initializes', () => {
+ it('renders', () => {
+ assert.instanceOf(element, MrChart);
+ });
+
+ it('sets this.projectname', () => {
+ assert.equal(element.projectName, 'rutabaga');
+ });
+ });
+
+ describe('data loading', () => {
+ beforeEach(() => {
+ // Stub MrChart.makeTimestamps to return 6, not 30 data points.
+ const originalMakeTimestamps = MrChart.makeTimestamps;
+ sinon.stub(MrChart, 'makeTimestamps').callsFake((endDate) => {
+ return originalMakeTimestamps(endDate, 1, 6);
+ });
+ sinon.stub(MrChart, 'getEndDate').callsFake(() => {
+ return new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+ });
+
+ // Re-instantiate element after stubs.
+ element = beforeEachElement();
+ });
+
+ afterEach(() => {
+ MrChart.makeTimestamps.restore();
+ MrChart.getEndDate.restore();
+ });
+
+ it('makes a series of XHR calls', async () => {
+ await dataLoadedPromise;
+ for (let i = 0; i < 6; i++) {
+ assert.deepEqual(element.values[i], new Map());
+ }
+ });
+
+ it('sets indices and correctly re-orders values', async () => {
+ await dataLoadedPromise;
+
+ const timestampMap = new Map([
+ [1540857599, 0], [1540943999, 1], [1541030399, 2], [1541116799, 3],
+ [1541203199, 4], [1541289599, 5],
+ ]);
+ sinon.stub(MrChart.prototype, '_fetchDataAtTimestamp').callsFake(
+ async (ts) => ({issues: {'Issue Count': timestampMap.get(ts)}}));
+
+ element.endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+ await element._fetchData();
+
+ assert.deepEqual(element.indices, [
+ '10/29/2018', '10/30/2018', '10/31/2018',
+ '11/1/2018', '11/2/2018', '11/3/2018',
+ ]);
+ for (let i = 0; i < 6; i++) {
+ assert.deepEqual(element.values[i], {'Issue Count': i});
+ }
+ MrChart.prototype._fetchDataAtTimestamp.restore();
+ });
+
+ it('if issue count is null, defaults to 0', async () => {
+ prpcClient.call.restore();
+ sinon.stub(prpcClient, 'call').callsFake(async () => {
+ return {snapshotCount: [{}]};
+ });
+ MrChart.makeTimestamps.restore();
+ sinon.stub(MrChart, 'makeTimestamps').callsFake((endDate) => {
+ return [1234567, 2345678, 3456789];
+ });
+
+ await element._fetchData(new Date());
+ assert.deepEqual(element.values[0], new Map());
+ });
+
+ it('Retrieve data under groupby feature', async () => {
+ const data = new Map([['Type-1', 0], ['Type-2', 1]]);
+ sinon.stub(MrChart.prototype, '_fetchDataAtTimestamp').callsFake(
+ () => ({issues: data}));
+
+ element = beforeEachElement();
+
+ await element._fetchData(new Date());
+ for (let i = 0; i < 3; i++) {
+ assert.deepEqual(element.values[i], data);
+ }
+ MrChart.prototype._fetchDataAtTimestamp.restore();
+ });
+
+ it('_fetchDataAtTimestamp has no default query or can', async () => {
+ await element._fetchData();
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Issues',
+ 'IssueSnapshot',
+ {
+ cannedQuery: undefined,
+ groupBy: undefined,
+ hotlistId: undefined,
+ query: undefined,
+ projectName: 'rutabaga',
+ timestamp: 1540857599,
+ });
+ });
+ });
+
+ describe('start date change detection', () => {
+ it('illegal query: start-date is greater than end-date', async () => {
+ await element.updateComplete;
+
+ element.startDate = new Date('2199-11-06');
+ element._fetchData();
+
+ assert.equal(element.dateRange, 90);
+ assert.equal(element.frequency, 7);
+ assert.equal(element.dateRangeNotLegal, true);
+ });
+
+ it('illegal query: end_date - start_date requires more than 90 queries',
+ async () => {
+ await element.updateComplete;
+
+ element.startDate = new Date('2016-10-03');
+ element._fetchData();
+
+ assert.equal(element.dateRange, 90 * 7);
+ assert.equal(element.frequency, 7);
+ assert.equal(element.maxQuerySizeReached, true);
+ });
+ });
+
+ describe('date change behavior', () => {
+ it('pushes to history API via pageJS', async () => {
+ sinon.stub(element, '_page');
+ sinon.spy(element, '_setDateRange');
+ sinon.spy(element, '_onDateChanged');
+ sinon.spy(element, '_changeUrlParams');
+
+ await element.updateComplete;
+
+ const thirtyButton = element.shadowRoot
+ .querySelector('#two-toggle').children[2];
+ thirtyButton.click();
+
+ sinon.assert.calledOnce(element._setDateRange);
+ sinon.assert.calledOnce(element._onDateChanged);
+ sinon.assert.calledOnce(element._changeUrlParams);
+ sinon.assert.calledOnce(element._page);
+
+ element._page.restore();
+ element._setDateRange.restore();
+ element._onDateChanged.restore();
+ element._changeUrlParams.restore();
+ });
+ });
+
+ describe('progress bar', () => {
+ it('visible based on loading progress', async () => {
+ // Check for visible progress bar and hidden input after initial render
+ await element.updateComplete;
+ const progressBar = element.shadowRoot.querySelector('progress');
+ const endDateInput = element.shadowRoot.querySelector('#end-date');
+ assert.isFalse(progressBar.hasAttribute('hidden'));
+ assert.isTrue(endDateInput.disabled);
+
+ // Check for hidden progress bar and enabled input after fetch and render
+ await dataLoadedPromise;
+ await element.updateComplete;
+ assert.isTrue(progressBar.hasAttribute('hidden'));
+ assert.isFalse(endDateInput.disabled);
+
+ // Trigger another data fetch and render, but prior to fetch complete
+ // Check progress bar is visible again
+ element.queryParams['start-date'] = '2012-01-01';
+ await element.requestUpdate('queryParams');
+ await element.updateComplete;
+ assert.isFalse(progressBar.hasAttribute('hidden'));
+
+ await dataLoadedPromise;
+ await element.updateComplete;
+ assert.isTrue(progressBar.hasAttribute('hidden'));
+ });
+ });
+
+ describe('static methods', () => {
+ describe('sortInBisectOrder', () => {
+ it('orders first, last, median recursively', () => {
+ assert.deepEqual(MrChart.sortInBisectOrder([]), []);
+ assert.deepEqual(MrChart.sortInBisectOrder([9]), [9]);
+ assert.deepEqual(MrChart.sortInBisectOrder([8, 9]), [8, 9]);
+ assert.deepEqual(MrChart.sortInBisectOrder([7, 8, 9]), [7, 9, 8]);
+ assert.deepEqual(
+ MrChart.sortInBisectOrder([1, 2, 3, 4, 5]), [1, 5, 3, 2, 4]);
+ });
+ });
+
+ describe('makeTimestamps', () => {
+ it('throws an error if endDate not passed', () => {
+ assert.throws(() => {
+ MrChart.makeTimestamps();
+ }, 'endDate required');
+ });
+ it('returns an array of in seconds', () => {
+ const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+ const secondsInDay = 24 * 60 * 60;
+
+ assert.deepEqual(MrChart.makeTimestamps(endDate, 1, 6), [
+ 1541289599 - (secondsInDay * 5), 1541289599 - (secondsInDay * 4),
+ 1541289599 - (secondsInDay * 3), 1541289599 - (secondsInDay * 2),
+ 1541289599 - (secondsInDay * 1), 1541289599 - (secondsInDay * 0),
+ ]);
+ });
+ it('tests frequency greater than 1', () => {
+ const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+ const secondsInDay = 24 * 60 * 60;
+
+ assert.deepEqual(MrChart.makeTimestamps(endDate, 2, 6), [
+ 1541289599 - (secondsInDay * 4),
+ 1541289599 - (secondsInDay * 2),
+ 1541289599 - (secondsInDay * 0),
+ ]);
+ });
+ it('tests frequency greater than 1', () => {
+ const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+ const secondsInDay = 24 * 60 * 60;
+
+ assert.deepEqual(MrChart.makeTimestamps(endDate, 2, 7), [
+ 1541289599 - (secondsInDay * 6),
+ 1541289599 - (secondsInDay * 4),
+ 1541289599 - (secondsInDay * 2),
+ 1541289599 - (secondsInDay * 0),
+ ]);
+ });
+ });
+
+ describe('dateStringToDate', () => {
+ it('returns null if no input', () => {
+ assert.isNull(MrChart.dateStringToDate());
+ });
+
+ it('returns a new Date at EOD UTC', () => {
+ const actualDate = MrChart.dateStringToDate('2018-11-03');
+ const expectedDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+ assert.equal(expectedDate.getTime(), 1541289599000, 'Sanity check.');
+
+ assert.equal(actualDate.getTime(), expectedDate.getTime());
+ });
+ });
+
+ describe('getEndDate', () => {
+ let clock;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers(10000);
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it('returns parsed input date', () => {
+ const input = '2018-11-03';
+
+ const expectedDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+ // Time sanity check.
+ assert.equal(Math.round(expectedDate.getTime() / 1e3), 1541289599);
+
+ const actual = MrChart.getEndDate(input);
+ assert.equal(actual.getTime(), expectedDate.getTime());
+ });
+
+ it('returns EOD of current date by default', () => {
+ const expectedDate = new Date();
+ expectedDate.setHours(23);
+ expectedDate.setMinutes(59);
+ expectedDate.setSeconds(59);
+
+ assert.equal(MrChart.getEndDate().getTime(),
+ expectedDate.getTime());
+ });
+ });
+
+ describe('getStartDate', () => {
+ let clock;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers(10000);
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it('returns parsed input date', () => {
+ const input = '2018-07-03';
+
+ const expectedDate = new Date(Date.UTC(2018, 6, 3, 23, 59, 59));
+ // Time sanity check.
+ assert.equal(Math.round(expectedDate.getTime() / 1e3), 1530662399);
+
+ const actual = MrChart.getStartDate(input);
+ assert.equal(actual.getTime(), expectedDate.getTime());
+ });
+
+ it('returns EOD of current date by default', () => {
+ const today = new Date();
+ today.setHours(23);
+ today.setMinutes(59);
+ today.setSeconds(59);
+
+ const secondsInDay = 24 * 60 * 60;
+ const expectedDate = new Date(today.getTime() -
+ 1000 * 90 * secondsInDay);
+ assert.equal(MrChart.getStartDate(undefined, today, 90).getTime(),
+ expectedDate.getTime());
+ });
+ });
+
+ describe('makeIndices', () => {
+ it('returns dates in mm/dd/yyy format', () => {
+ const timestamps = [
+ 1540857599, 1540943999, 1541030399,
+ 1541116799, 1541203199, 1541289599,
+ ];
+ assert.deepEqual(MrChart.makeIndices(timestamps), [
+ '10/29/2018', '10/30/2018', '10/31/2018',
+ '11/1/2018', '11/2/2018', '11/3/2018',
+ ]);
+ });
+ });
+
+ describe('getPredictedData', () => {
+ it('get predicted data shown in daily', () => {
+ const values = [0, 1, 2, 3, 4, 5, 6];
+ const result = MrChart.getPredictedData(
+ values, values.length, 3, 1, new Date('10-02-2017'));
+ assert.deepEqual(result[0], ['10/4/2017', '10/5/2017', '10/6/2017']);
+ assert.deepEqual(result[1], [7, 8, 9]);
+ assert.deepEqual(result[2], [0, 1, 2, 3, 4, 5, 6]);
+ });
+
+ it('get predicted data shown in weekly', () => {
+ const values = [0, 7, 14, 21, 28, 35, 42, 49, 56, 63, 70, 77, 84];
+ const result = MrChart.getPredictedData(
+ values, 91, 13, 7, new Date('10-02-2017'));
+ assert.deepEqual(result[1], values.map((x) => x+91));
+ assert.deepEqual(result[2], values);
+ });
+ });
+
+ describe('getErrorData', () => {
+ it('get error data with perfect regression', () => {
+ const values = [0, 1, 2, 3, 4, 5, 6];
+ const result = MrChart.getErrorData(values, values, [7, 8, 9]);
+ assert.deepEqual(result[0], [7, 8, 9]);
+ assert.deepEqual(result[1], [7, 8, 9]);
+ });
+
+ it('get error data with nonperfect regression', () => {
+ const values = [0, 1, 3, 4, 6, 6, 7];
+ const result = MrChart.getPredictedData(
+ values, values.length, 3, 1, new Date('10-02-2017'));
+ const error = MrChart.getErrorData(result[2], values, result[1]);
+ assert.isTrue(error[0][0] > result[1][0]);
+ assert.isTrue(error[1][0] < result[1][0]);
+ });
+ });
+
+ describe('getSortedLines', () => {
+ it('return all lines for less than n lines', () => {
+ const arrayValues = [
+ {label: 'line1', data: [0, 0, 1]},
+ {label: 'line2', data: [0, 1, 2]},
+ {label: 'line3', data: [0, 1, 0]},
+ {label: 'line4', data: [4, 0, 3]},
+ ];
+ const expectedValues = [
+ {label: 'line1', data: [0, 0, 1]},
+ {label: 'line2', data: [0, 1, 2]},
+ {label: 'line3', data: [0, 1, 0]},
+ {label: 'line4', data: [4, 0, 3]},
+ ];
+ const actualValues = MrChart.getSortedLines(arrayValues, 4);
+ for (let i = 0; i < 4; i++) {
+ assert.deepEqual(expectedValues[i], actualValues[i]);
+ }
+ });
+
+ it('return top n lines in sorted order for more than n lines',
+ () => {
+ const arrayValues = [
+ {label: 'line1', data: [0, 0, 1]},
+ {label: 'line2', data: [0, 1, 2]},
+ {label: 'line3', data: [0, 4, 0]},
+ {label: 'line4', data: [4, 0, 3]},
+ {label: 'line5', data: [0, 2, 3]},
+ ];
+ const expectedValues = [
+ {label: 'line5', data: [0, 2, 3]},
+ {label: 'line4', data: [4, 0, 3]},
+ {label: 'line2', data: [0, 1, 2]},
+ ];
+ const actualValues = MrChart.getSortedLines(arrayValues, 3);
+ for (let i = 0; i < 3; i++) {
+ assert.deepEqual(expectedValues[i], actualValues[i]);
+ }
+ });
+ });
+
+ describe('getGroupByFromQuery', () => {
+ it('get group by label object from URL', () => {
+ const input = {'groupby': 'label', 'labelprefix': 'Type'};
+
+ const expectedGroupBy = {
+ value: 'label',
+ labelPrefix: 'Type',
+ display: 'Type',
+ };
+ assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+ });
+
+ it('get group by is open object from URL', () => {
+ const input = {'groupby': 'open'};
+
+ const expectedGroupBy = {value: 'open', display: 'Is open'};
+ assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+ });
+
+ it('get group by none object from URL', () => {
+ const input = {'groupby': ''};
+
+ const expectedGroupBy = {value: '', display: 'None'};
+ assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+ });
+
+ it('only returns valid groupBy values', () => {
+ const invalidKeys = ['pri', 'reporter', 'stars'];
+
+ const queryParams = {groupBy: ''};
+
+ invalidKeys.forEach((key) => {
+ queryParams.groupBy = key;
+ const expected = {value: '', display: 'None'};
+ const result = MrChart.getGroupByFromQuery(queryParams);
+ assert.deepEqual(result, expected);
+ });
+ });
+ });
+ });
+
+ describe('subscribedQuery', () => {
+ it('includes start and end date', () => {
+ assert.isTrue(subscribedQuery.has('start-date'));
+ assert.isTrue(subscribedQuery.has('start-date'));
+ });
+
+ it('includes groupby and labelprefix', () => {
+ assert.isTrue(subscribedQuery.has('groupby'));
+ assert.isTrue(subscribedQuery.has('labelprefix'));
+ });
+
+ it('includes q and can', () => {
+ assert.isTrue(subscribedQuery.has('q'));
+ assert.isTrue(subscribedQuery.has('can'));
+ });
+ });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/extract-grid-data.js b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.js
new file mode 100644
index 0000000..ebfa510
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.js
@@ -0,0 +1,203 @@
+// 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 {EMPTY_FIELD_VALUE, fieldTypes} from 'shared/issue-fields.js';
+import 'shared/typedef.js';
+
+
+const DEFAULT_HEADER_VALUE = 'All';
+
+// Sort headings functions
+// TODO(zhangtiff): Find some way to restructure this code to allow
+// sorting functions to sort with raw types instead of stringified values.
+
+/**
+ * Used as an optional 'compareFunction' for Array.sort().
+ * @param {string} strA
+ * @param {string} strB
+ * @return {number}
+ */
+function intStrComparator(strA, strB) {
+ return parseInt(strA) - parseInt(strB);
+}
+
+/**
+ * Used as an optional 'compareFunction' for Array.sort()
+ * @param {string} issueRefStrA
+ * @param {string} issueRefStrB
+ * @return {number}
+ */
+function issueRefComparator(issueRefStrA, issueRefStrB) {
+ const issueRefA = issueRefStrA.split(':');
+ const issueRefB = issueRefStrB.split(':');
+ if (issueRefA[0] != issueRefB[0]) {
+ return issueRefStrA.localeCompare(issueRefStrB);
+ } else {
+ return parseInt(issueRefA[1]) - parseInt(issueRefB[1]);
+ }
+}
+
+/**
+ * Returns a comparator for strings representing statuses using the ordering
+ * provided in statusDefs.
+ * Any status not found in statusDefs will be sorted to the end.
+ * @param {!Array<StatusDef>=} statusDefs
+ * @return {function(string, string): number}
+ */
+function getStatusDefComparator(statusDefs = []) {
+ return (statusStrA, statusStrB) => {
+ // Traverse statusDefs to determine which status is first.
+ for (const statusDef of statusDefs) {
+ if (statusDef.status == statusStrA) {
+ return -1;
+ } else if (statusDef.status == statusStrB) {
+ return 1;
+ }
+ }
+ return 0;
+ };
+}
+
+/**
+ * @param {!Set<string>} headingSet The headers found for the field.
+ * @param {string} fieldName The field on which we're sorting.
+ * @param {function(string): string=} extractTypeForFieldName
+ * @param {!Array<StatusDef>=} statusDefs
+ * @return {!Array<string>}
+ */
+function sortHeadings(headingSet, fieldName, extractTypeForFieldName,
+ statusDefs = []) {
+ let sorter;
+ if (extractTypeForFieldName) {
+ const type = extractTypeForFieldName(fieldName);
+ if (type === fieldTypes.ISSUE_TYPE) {
+ sorter = issueRefComparator;
+ } else if (type === fieldTypes.INT_TYPE) {
+ sorter = intStrComparator;
+ } else if (type === fieldTypes.STATUS_TYPE) {
+ sorter = getStatusDefComparator(statusDefs);
+ }
+ }
+
+ // Track whether EMPTY_FIELD_VALUE is present, and ensure that
+ // it is sorted to the first position of custom fields.
+ // TODO(jessan): although convenient, it is bad practice to mutate parameters.
+ const hasEmptyFieldValue = headingSet.delete(EMPTY_FIELD_VALUE);
+ const headingsList = [...headingSet];
+
+ headingsList.sort(sorter);
+
+ if (hasEmptyFieldValue) {
+ headingsList.unshift(EMPTY_FIELD_VALUE);
+ }
+ return headingsList;
+}
+
+/**
+ * @param {string} x Header value.
+ * @param {string} y Header value.
+ * @return {string} The key for the groupedIssue map.
+ * TODO(jessan): Make a GridData class, which avoids exposing this logic.
+ */
+export function makeGridCellKey(x, y) {
+ // Note: Some possible x and y values contain ':', '-', and other
+ // non-word characters making delimiter options limited.
+ return x + ' + ' + y;
+}
+
+/**
+ * @param {Issue} issue The issue for which we're preparing grid headings.
+ * @param {string} fieldName The field on which we're grouping.
+ * @param {function(Issue, string): Array<string>} extractFieldValuesFromIssue
+ * @return {!Array<string>} The headings the issue should be grouped into.
+ */
+function prepareHeadings(
+ issue, fieldName, extractFieldValuesFromIssue) {
+ const values = extractFieldValuesFromIssue(issue, fieldName);
+
+ return values.length == 0 ?
+ [EMPTY_FIELD_VALUE] :
+ values;
+}
+
+/**
+ * Groups issues by their values for the given fields.
+ * @param {Array<Issue>} required.issues The issues we are grouping
+ * @param {function(Issue, string): Array<string>}
+ * required.extractFieldValuesFromIssue
+ * @param {string=} options.xFieldName name of the field for grouping columns
+ * @param {string=} options.yFieldName name of the field for grouping rows
+ * @param {function(string): string=} options.extractTypeForFieldName
+ * @param {Array=} options.statusDefs
+ * @param {Map=} options.labelPrefixValueMap
+ * @return {!Object} Grid data
+ * - groupedIssues: A map of issues grouped by thir xField and yField values.
+ * - xHeadings: sorted headings for columns.
+ * - yHeadings: sorted headings for rows.
+ */
+export function extractGridData({issues, extractFieldValuesFromIssue}, {
+ xFieldName = '',
+ yFieldName = '',
+ extractTypeForFieldName = undefined,
+ statusDefs = [],
+ labelPrefixValueMap = new Map(),
+} = {}) {
+ const xHeadingsPredefinedSet = new Set();
+ const xHeadingsAdHocSet = new Set();
+ const yHeadingsSet = new Set();
+ const groupedIssues = new Map();
+ for (const issue of issues) {
+ const xHeadings = !xFieldName ?
+ [DEFAULT_HEADER_VALUE] :
+ prepareHeadings(
+ issue, xFieldName, extractFieldValuesFromIssue);
+ const yHeadings = !yFieldName ?
+ [DEFAULT_HEADER_VALUE] :
+ prepareHeadings(
+ issue, yFieldName, extractFieldValuesFromIssue);
+
+ // Find every combo of 'xValue yValue' that the issue belongs to
+ // and add it into that cell. Also record each header used.
+ for (const xHeading of xHeadings) {
+ if (labelPrefixValueMap.has(xFieldName) &&
+ labelPrefixValueMap.get(xFieldName).has(xHeading)) {
+ xHeadingsPredefinedSet.add(xHeading);
+ } else {
+ xHeadingsAdHocSet.add(xHeading);
+ }
+ for (const yHeading of yHeadings) {
+ yHeadingsSet.add(yHeading);
+ const cellKey = makeGridCellKey(xHeading, yHeading);
+ if (groupedIssues.has(cellKey)) {
+ groupedIssues.get(cellKey).push(issue);
+ } else {
+ groupedIssues.set(cellKey, [issue]);
+ }
+ }
+ }
+ }
+
+ // Predefined labels to be ordered in front of ad hoc labels
+ const xHeadings = [
+ ...sortHeadings(
+ xHeadingsPredefinedSet,
+ xFieldName,
+ extractTypeForFieldName,
+ statusDefs,
+ ),
+ ...sortHeadings(
+ xHeadingsAdHocSet,
+ xFieldName,
+ extractTypeForFieldName,
+ statusDefs,
+ ),
+ ];
+
+ return {
+ groupedIssues,
+ xHeadings,
+ yHeadings: sortHeadings(yHeadingsSet, yFieldName, extractTypeForFieldName,
+ statusDefs),
+ };
+}
diff --git a/static_src/elements/issue-list/mr-grid-page/extract-grid-data.test.js b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.test.js
new file mode 100644
index 0000000..41d5c70
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.test.js
@@ -0,0 +1,289 @@
+// 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 {extractGridData} from './extract-grid-data.js';
+import {extractFieldValuesFromIssue as fieldExtractor,
+ extractTypeForFieldName as typeExtractor} from 'reducers/projectV0.js';
+
+const extractFieldValuesFromIssue = fieldExtractor({});
+const extractTypeForFieldName = typeExtractor({});
+
+
+describe('extract headings from x and y attributes', () => {
+ it('no attributes set', () => {
+ const issues = [
+ {'localId': 1, 'projectName': 'test'},
+ {'localId': 2, 'projectName': 'test'},
+ ];
+
+ const data = extractGridData({
+ issues,
+ extractFieldValuesFromIssue,
+ });
+
+ const expectedIssues = new Map([
+ ['All + All', [
+ {'localId': 1, 'projectName': 'test'},
+ {'localId': 2, 'projectName': 'test'},
+ ]],
+ ]);
+
+ assert.deepEqual(data.xHeadings, ['All']);
+ assert.deepEqual(data.yHeadings, ['All']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from Attachments attribute', () => {
+ const issues = [
+ {'attachmentCount': 1}, {'attachmentCount': 0},
+ {'attachmentCount': 1},
+ ];
+
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: 'Attachments'});
+
+ const expectedIssues = new Map([
+ ['0 + All', [{'attachmentCount': 0}]],
+ ['1 + All', [{'attachmentCount': 1}, {'attachmentCount': 1}]],
+ ]);
+
+ assert.deepEqual(data.xHeadings, ['0', '1']);
+ assert.deepEqual(data.yHeadings, ['All']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from Blocked attribute', () => {
+ const issues = [
+ {'blockedOnIssueRefs': [{'localId': 21}]},
+ {'otherIssueProperty': 'issueProperty'},
+ ];
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: 'Blocked', yFieldName: ''});
+
+ const expectedIssues = new Map();
+ expectedIssues.set('Yes + All',
+ [{'blockedOnIssueRefs': [{'localId': 21}]}]);
+ expectedIssues.set('No + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+ assert.deepEqual(data.xHeadings, ['No', 'Yes']);
+ assert.deepEqual(data.yHeadings, ['All']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from BlockedOn attribute', () => {
+ const issues = [
+ {'otherIssueProperty': 'issueProperty'},
+ {'blockedOnIssueRefs': [
+ {'localId': 3, 'projectName': 'test-projectB'}]},
+ {'blockedOnIssueRefs': [
+ {'localId': 3, 'projectName': 'test-projectA'}]},
+ {'blockedOnIssueRefs': [
+ {'localId': 3, 'projectName': 'test-projectA'}]},
+ {'blockedOnIssueRefs': [
+ {'localId': 1, 'projectName': 'test-projectA'}]},
+ ];
+
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: 'BlockedOn', yFieldName: ''});
+
+ const expectedIssues = new Map();
+ expectedIssues.set('test-projectB:3 + All', [{'blockedOnIssueRefs':
+ [{'localId': 3, 'projectName': 'test-projectB'}]}]);
+ expectedIssues.set('test-projectA:3 + All', [{'blockedOnIssueRefs':
+ [{'localId': 3, 'projectName': 'test-projectA'}]},
+ {'blockedOnIssueRefs':
+ [{'localId': 3, 'projectName': 'test-projectA'}]}]);
+ expectedIssues.set('test-projectA:1 + All', [{'blockedOnIssueRefs':
+ [{'localId': 1, 'projectName': 'test-projectA'}]}]);
+ expectedIssues.set('---- + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+ assert.deepEqual(data.xHeadings, ['----', 'test-projectA:1',
+ 'test-projectA:3', 'test-projectB:3']);
+ assert.deepEqual(data.yHeadings, ['All']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from Blocking attribute', () => {
+ const issues = [
+ {'otherIssueProperty': 'issueProperty'},
+ {'blockingIssueRefs': [
+ {'localId': 1, 'projectName': 'test-projectA'}]},
+ {'blockingIssueRefs': [
+ {'localId': 1, 'projectName': 'test-projectA'}]},
+ {'blockingIssueRefs': [
+ {'localId': 3, 'projectName': 'test-projectA'}]},
+ {'blockingIssueRefs': [
+ {'localId': 3, 'projectName': 'test-projectB'}]},
+ ];
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: 'Blocking', yFieldName: ''});
+
+ const expectedIssues = new Map();
+ expectedIssues.set('test-projectA:1 + All', [{'blockingIssueRefs':
+ [{'localId': 1, 'projectName': 'test-projectA'}]},
+ {'blockingIssueRefs':
+ [{'localId': 1, 'projectName': 'test-projectA'}]}]);
+ expectedIssues.set('test-projectA:3 + All', [{'blockingIssueRefs':
+ [{'localId': 3, 'projectName': 'test-projectA'}]}]);
+ expectedIssues.set('test-projectB:3 + All', [{'blockingIssueRefs':
+ [{'localId': 3, 'projectName': 'test-projectB'}]}]);
+ expectedIssues.set('---- + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+ assert.deepEqual(data.xHeadings, ['----', 'test-projectA:1',
+ 'test-projectA:3', 'test-projectB:3']);
+ assert.deepEqual(data.yHeadings, ['All']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from Component attribute', () => {
+ const issues = [
+ {'otherIssueProperty': 'issueProperty'},
+ {'componentRefs': [{'path': 'UI'}]},
+ {'componentRefs': [{'path': 'API'}]},
+ {'componentRefs': [{'path': 'UI'}]},
+ ];
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: 'Component', yFieldName: ''});
+
+ const expectedIssues = new Map();
+ expectedIssues.set('UI + All', [{'componentRefs': [{'path': 'UI'}]},
+ {'componentRefs': [{'path': 'UI'}]}]);
+ expectedIssues.set('API + All', [{'componentRefs': [{'path': 'API'}]}]);
+ expectedIssues.set('---- + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+ assert.deepEqual(data.xHeadings, ['----', 'API', 'UI']);
+ assert.deepEqual(data.yHeadings, ['All']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from Reporter attribute', () => {
+ const issues = [
+ {'reporterRef': {'displayName': 'testA@google.com'}},
+ {'reporterRef': {'displayName': 'testB@google.com'}},
+ ];
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: '', yFieldName: 'Reporter'});
+
+ const expectedIssues = new Map();
+ expectedIssues.set('All + testA@google.com',
+ [{'reporterRef': {'displayName': 'testA@google.com'}}]);
+ expectedIssues.set('All + testB@google.com',
+ [{'reporterRef': {'displayName': 'testB@google.com'}}]);
+
+ assert.deepEqual(data.xHeadings, ['All']);
+ assert.deepEqual(data.yHeadings, ['testA@google.com', 'testB@google.com']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from Stars attribute', () => {
+ const issues = [
+ {'starCount': 1}, {'starCount': 6}, {'starCount': 1},
+ ];
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: '', yFieldName: 'Stars'});
+
+ const expectedIssues = new Map();
+ expectedIssues.set('All + 1', [{'starCount': 1}, {'starCount': 1}]);
+ expectedIssues.set('All + 6', [{'starCount': 6}]);
+
+ assert.deepEqual(data.xHeadings, ['All']);
+ assert.deepEqual(data.yHeadings, ['1', '6']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from Status in order of statusDefs provided', () => {
+ const issues = [
+ {'statusRef': {'status': 'New'}},
+ {'statusRef': {'status': '1Unknown'}},
+ {'statusRef': {'status': 'Accepted'}},
+ {'statusRef': {'status': 'New'}},
+ {'statusRef': {'status': 'UltraNew'}},
+ ];
+ const statusDefs = [
+ {status: 'UltraNew'}, {status: 'New'}, {status: 'Unused'},
+ {status: 'Accepted'},
+ ];
+
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {yFieldName: 'Status', extractTypeForFieldName, statusDefs});
+
+ const expectedIssues = new Map();
+ expectedIssues.set(
+ 'All + Accepted', [{'statusRef': {'status': 'Accepted'}}]);
+ expectedIssues.set(
+ 'All + New',
+ [{'statusRef': {'status': 'New'}}, {'statusRef': {'status': 'New'}}]);
+ expectedIssues.set(
+ 'All + UltraNew', [{'statusRef': {'status': 'UltraNew'}}]);
+ expectedIssues.set(
+ 'All + 1Unknown', [{'statusRef': {'status': '1Unknown'}}]);
+ assert.deepEqual(data.xHeadings, ['All']);
+ assert.deepEqual(
+ data.yHeadings, ['UltraNew', 'New', 'Accepted', '1Unknown']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('extract headings from the Type attribute', () => {
+ const issues = [
+ {'labelRefs': [{'label': 'Pri-2'}, {'label': 'Milestone-2000Q1'}]},
+ {'labelRefs': [{'label': 'Type-Defect'}]},
+ {'labelRefs': [{'label': 'Type-Defect'}]},
+ {'labelRefs': [{'label': 'Type-Enhancement'}]},
+ ];
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {yFieldName: 'Type'});
+
+ const expectedIssues = new Map();
+ expectedIssues.set('All + Defect', [
+ {'labelRefs': [{'label': 'Type-Defect'}]},
+ {'labelRefs': [{'label': 'Type-Defect'}]},
+ ]);
+ expectedIssues.set('All + Enhancement', [{'labelRefs':
+ [{'label': 'Type-Enhancement'}]}]);
+ expectedIssues.set('All + ----', [{'labelRefs':
+ [{'label': 'Pri-2'}, {'label': 'Milestone-2000Q1'}]}]);
+
+ assert.deepEqual(data.xHeadings, ['All']);
+ assert.deepEqual(data.yHeadings, ['----', 'Defect', 'Enhancement']);
+ assert.deepEqual(data.groupedIssues, expectedIssues);
+ });
+
+ it('puts predefined labels ahead of ad hoc labels', () => {
+ const issues = [
+ {'labelRefs': [{'label': 'Pri-2'}, {'label': 'Type-Defect'}]},
+ {'labelRefs': [{'label': 'Type-Defect'}]},
+ {'labelRefs': [{'label': 'Type-Enhancement'}]},
+ {'labelRefs': [{'label': 'Type-AAA'}]},
+ ];
+ const labelPrefixValueMap = new Map([
+ ['Pri', new Set(['2'])],
+ ['Type', new Set(['Defect', 'Enhancement'])],
+ ]);
+
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: 'Type', yFieldName: 'Pri', labelPrefixValueMap});
+
+ assert.deepEqual(data.xHeadings, ['Defect', 'Enhancement', 'AAA']);
+ assert.deepEqual(data.yHeadings, ['----', '2']);
+ });
+
+ it('has priority order of predefined, empty, then ad hoc labels', () => {
+ const issues = [
+ {'labelRefs': [{'label': 'Pri-2'}, {'label': 'Milestone-2000Q1'}]},
+ {'labelRefs': [{'label': 'Type-Defect'}]},
+ {'labelRefs': [{'label': 'Type-Enhancement'}]},
+ {'labelRefs': [{'label': 'Type-AAA'}]},
+ ];
+ const labelPrefixValueMap = new Map([
+ ['Pri', new Set(['2'])],
+ ['Type', new Set(['Defect', 'Enhancement'])],
+ ]);
+
+ const data = extractGridData({issues, extractFieldValuesFromIssue},
+ {xFieldName: 'Type', yFieldName: 'Pri', labelPrefixValueMap});
+
+ assert.deepEqual(data.xHeadings, ['Defect', 'Enhancement', '----', 'AAA']);
+ });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.js
new file mode 100644
index 0000000..2fe01ea
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.js
@@ -0,0 +1,255 @@
+// 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 page from 'page';
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import 'elements/chops/chops-choice-buttons/chops-choice-buttons.js';
+import '../mr-mode-selector/mr-mode-selector.js';
+import './mr-grid-dropdown.js';
+import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
+import {urlWithNewParams} from 'shared/helpers.js';
+import {fieldsForIssue} from 'shared/issue-fields.js';
+
+// A list of the valid default field names available in an issue grid.
+// High cardinality fields must be excluded, so the grid only includes a subset
+// of AVAILABLE FIELDS.
+export const DEFAULT_GRID_FIELDS = Object.freeze([
+ 'Project',
+ 'Attachments',
+ 'Blocked',
+ 'BlockedOn',
+ 'Blocking',
+ 'Component',
+ 'MergedInto',
+ 'Reporter',
+ 'Stars',
+ 'Status',
+ 'Type',
+ 'Owner',
+]);
+
+/**
+ * Component for displaying the controls shown on the Monorail issue grid page.
+ * @extends {LitElement}
+ */
+export class MrGridControls extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-sizing: border-box;
+ margin: 0.5em 0;
+ height: 32px;
+ }
+ mr-grid-dropdown {
+ padding-right: 20px;
+ }
+ .left-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ flex-grow: 0;
+ }
+ .right-controls {
+ display: flex;
+ align-items: center;
+ flex-grow: 0;
+ }
+ .issue-count {
+ display: inline-block;
+ padding-right: 20px;
+ }
+ `;
+ };
+
+ /** @override */
+ render() {
+ const hideCounts = this.totalIssues === 0;
+ return html`
+ <div class="left-controls">
+ <mr-grid-dropdown
+ class="row-selector"
+ .text=${'Rows'}
+ .items=${this.gridOptions}
+ .selection=${this.queryParams.y}
+ @change=${this._rowChanged}>
+ </mr-grid-dropdown>
+ <mr-grid-dropdown
+ class="col-selector"
+ .text=${'Cols'}
+ .items=${this.gridOptions}
+ .selection=${this.queryParams.x}
+ @change=${this._colChanged}>
+ </mr-grid-dropdown>
+ <chops-choice-buttons
+ class="cell-selector"
+ .options=${this.cellOptions}
+ .value=${this.cellType}>
+ </chops-choice-buttons>
+ </div>
+ <div class="right-controls">
+ ${hideCounts ? '' : html`
+ <div class="issue-count">
+ ${this.issueCount}
+ of
+ ${this.totalIssuesDisplay}
+ </div>
+ `}
+ <mr-mode-selector
+ .projectName=${this.projectName}
+ .queryParams=${this.queryParams}
+ value="grid"
+ ></mr-mode-selector>
+ </div>
+ `;
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.gridOptions = this._computeGridOptions([]);
+ this.queryParams = {};
+
+ this.totalIssues = 0;
+
+ this._page = page;
+ };
+
+ /** @override */
+ static get properties() {
+ return {
+ gridOptions: {type: Array},
+ projectName: {tupe: String},
+ queryParams: {type: Object},
+ issueCount: {type: Number},
+ totalIssues: {type: Number},
+ _issues: {type: Array},
+ };
+ };
+
+ /** @override */
+ stateChanged(state) {
+ this.totalIssues = issueV0.totalIssues(state) || 0;
+ this._issues = issueV0.issueList(state) || [];
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('_issues')) {
+ this.gridOptions = this._computeGridOptions(this._issues);
+ }
+ super.update(changedProperties);
+ }
+
+ /**
+ * Gets what issue filtering options exist on the grid view.
+ * @param {Array<Issue>} issues The issues to find values on.
+ * @param {Array<string>=} defaultFields Available built in fields.
+ * @return {Array<string>} Array of names of fields you can filter by.
+ */
+ _computeGridOptions(issues, defaultFields = DEFAULT_GRID_FIELDS) {
+ const availableFields = new Set(defaultFields);
+ issues.forEach((issue) => {
+ fieldsForIssue(issue, true).forEach((field) => {
+ availableFields.add(field);
+ });
+ });
+ const options = [...availableFields].sort();
+ options.unshift('None');
+ return options;
+ }
+
+ /**
+ * @return {string} Display text of total issue number.
+ */
+ get totalIssuesDisplay() {
+ if (this.issueCount === 1) {
+ return `${this.issueCount} issue shown`;
+ } else if (this.issueCount === SERVER_LIST_ISSUES_LIMIT) {
+ // Server has hard limit up to 100,000 list results
+ return `100,000+ issues shown`;
+ }
+ return `${this.issueCount} issues shown`;
+ }
+
+ /**
+ * @return {string} What cell mode the user has selected.
+ * ie: Tiles, IDs, Counts
+ */
+ get cellType() {
+ const cells = this.queryParams.cells;
+ return cells || 'tiles';
+ }
+
+ /**
+ * @return {Array<Object>} Cell options available to the user, formatted for
+ * <mr-mode-selector>
+ */
+ get cellOptions() {
+ return [
+ {text: 'Tile', value: 'tiles',
+ url: this._updatedGridViewUrl({}, ['cells'])},
+ {text: 'IDs', value: 'ids',
+ url: this._updatedGridViewUrl({cells: 'ids'})},
+ {text: 'Counts', value: 'counts',
+ url: this._updatedGridViewUrl({cells: 'counts'})},
+ ];
+ }
+
+ /**
+ * Changes the URL parameters on the page in response to a user changing
+ * their row setting.
+ * @param {Event} e 'change' event fired by <mr-grid-dropdown>
+ */
+ _rowChanged(e) {
+ const y = e.target.selection;
+ let deletedParams;
+ if (y === 'None') {
+ deletedParams = ['y'];
+ }
+ this._changeUrlParams({y}, deletedParams);
+ }
+
+ /**
+ * Changes the URL parameters on the page in response to a user changing
+ * their col setting.
+ * @param {Event} e 'change' event fired by <mr-grid-dropdown>
+ */
+ _colChanged(e) {
+ const x = e.target.selection;
+ let deletedParams;
+ if (x === 'None') {
+ deletedParams = ['x'];
+ }
+ this._changeUrlParams({x}, deletedParams);
+ }
+
+ /**
+ * Helper method to update URL params with a new grid view URL.
+ * @param {Array<Object>} newParams
+ * @param {Array<string>} deletedParams
+ */
+ _changeUrlParams(newParams, deletedParams) {
+ const newUrl = this._updatedGridViewUrl(newParams, deletedParams);
+ this._page(newUrl);
+ }
+
+ /**
+ * Helper to generate a new grid view URL given a set of params.
+ * @param {Array<Object>} newParams
+ * @param {Array<string>} deletedParams
+ * @return {string} The generated URL.
+ */
+ _updatedGridViewUrl(newParams, deletedParams) {
+ return urlWithNewParams(`/p/${this.projectName}/issues/list`,
+ this.queryParams, newParams, deletedParams);
+ }
+};
+
+customElements.define('mr-grid-controls', MrGridControls);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.test.js
new file mode 100644
index 0000000..d6d7fbf
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.test.js
@@ -0,0 +1,111 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {MrGridControls} from './mr-grid-controls.js';
+import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
+
+let element;
+
+describe('mr-grid-controls', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-grid-controls');
+ document.body.appendChild(element);
+
+ element._page = sinon.stub();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrGridControls);
+ });
+
+ it('selecting row updates y param', async () => {
+ const stub = sinon.stub(element, '_changeUrlParams');
+
+ await element.updateComplete;
+
+ const dropdownRows = element.shadowRoot.querySelector('.row-selector');
+
+ dropdownRows.selection = 'Status';
+ dropdownRows.dispatchEvent(new Event('change'));
+ sinon.assert.calledWith(stub, {y: 'Status'});
+ });
+
+ it('setting row to None deletes y param', async () => {
+ element.queryParams = {y: 'Remove', x: 'Keep'};
+ element.projectName = 'chromium';
+
+ await element.updateComplete;
+
+ const dropdownRows = element.shadowRoot.querySelector('.row-selector');
+
+ dropdownRows.selection = 'None';
+ dropdownRows.dispatchEvent(new Event('change'));
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?x=Keep');
+ });
+
+ it('selecting col updates x param', async () => {
+ const stub = sinon.stub(element, '_changeUrlParams');
+ await element.updateComplete;
+
+ const dropdownCols = element.shadowRoot.querySelector('.col-selector');
+
+ dropdownCols.selection = 'Blocking';
+ dropdownCols.dispatchEvent(new Event('change'));
+ sinon.assert.calledWith(stub, {x: 'Blocking'});
+ });
+
+ it('setting col to None deletes x param', async () => {
+ element.queryParams = {y: 'Keep', x: 'Remove'};
+ element.projectName = 'chromium';
+
+ await element.updateComplete;
+
+ const dropdownCols = element.shadowRoot.querySelector('.col-selector');
+
+ dropdownCols.selection = 'None';
+ dropdownCols.dispatchEvent(new Event('change'));
+
+ sinon.assert.calledWith(element._page,
+ '/p/chromium/issues/list?y=Keep');
+ });
+
+ it('cellOptions computes URLs with queryParams and projectName', async () => {
+ element.projectName = 'chromium';
+ element.queryParams = {q: 'hello-world'};
+
+ assert.deepEqual(element.cellOptions, [
+ {text: 'Tile', value: 'tiles',
+ url: '/p/chromium/issues/list?q=hello-world'},
+ {text: 'IDs', value: 'ids',
+ url: '/p/chromium/issues/list?q=hello-world&cells=ids'},
+ {text: 'Counts', value: 'counts',
+ url: '/p/chromium/issues/list?q=hello-world&cells=counts'},
+ ]);
+ });
+
+ describe('displays appropriate messaging for issue count', () => {
+ it('for one issue', () => {
+ element.issueCount = 1;
+ assert.equal(element.totalIssuesDisplay, '1 issue shown');
+ });
+
+ it('for less than 100,000 issues', () => {
+ element.issueCount = 50;
+ assert.equal(element.totalIssuesDisplay, '50 issues shown');
+ });
+
+ it('for 100,000 issues or more', () => {
+ element.issueCount = SERVER_LIST_ISSUES_LIMIT;
+ assert.equal(element.totalIssuesDisplay, '100,000+ issues shown');
+ });
+ });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.js
new file mode 100644
index 0000000..2fc05b6
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.js
@@ -0,0 +1,72 @@
+// 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 {equalsIgnoreCase} from 'shared/helpers.js';
+
+/**
+ * `<mr-grid-dropdown>`
+ *
+ * Component used by the user to select what grid options to use.
+ */
+export class MrGridDropdown extends LitElement {
+ /** @override */
+ render() {
+ return html`
+ ${this.text}:
+ <select
+ class="drop-down"
+ @change=${this._optionChanged}
+ >
+ ${(this.items).map((item) => html`
+ <option .selected=${equalsIgnoreCase(item, this.selection)}>
+ ${item}
+ </option>
+ `)}
+ </select>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ text: {type: String},
+ items: {type: Array},
+ selection: {type: String},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+ this.items = [];
+ this.selection = 'None';
+ };
+
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ font-size: var(--chops-large-font-size);
+ }
+ .drop-down {
+ font-size: var(--chops-large-font-size);
+ }
+ `;
+ };
+
+ /**
+ * Syncs values when the user updates their selection.
+ * @param {Event} e
+ * @fires CustomEvent#change
+ * @private
+ */
+ _optionChanged(e) {
+ this.selection = e.target.value;
+ this.dispatchEvent(new CustomEvent('change'));
+ };
+};
+
+customElements.define('mr-grid-dropdown', MrGridDropdown);
+
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.test.js
new file mode 100644
index 0000000..fcd480d
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.test.js
@@ -0,0 +1,22 @@
+// 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 {MrGridDropdown} from './mr-grid-dropdown.js';
+
+let element;
+
+describe('mr-grid-dropdown', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-grid-dropdown');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrGridDropdown);
+ });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-page.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.js
new file mode 100644
index 0000000..d96e566
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.js
@@ -0,0 +1,180 @@
+// 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.
+
+// TODO(juliacordero): Handle pRPC errors with a FE page
+
+import {LitElement, html, css} from 'lit-element';
+import {store, connectStore} from 'reducers/base.js';
+import {shouldWaitForDefaultQuery} from 'shared/helpers.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import './mr-grid-controls.js';
+import './mr-grid.js';
+
+/**
+ * <mr-grid-page>
+ *
+ * Grid page view containing mr-grid and mr-grid-controls.
+ * @extends {LitElement}
+ */
+export class MrGridPage extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ const displayedProgress = this.progress || 0.02;
+ const doneLoading = this.progress === 1;
+ const noMatches = this.totalIssues === 0 && doneLoading;
+ return html`
+ <div id="grid-area">
+ <mr-grid-controls
+ .projectName=${this.projectName}
+ .queryParams=${this._queryParams}
+ .issueCount=${this.issues.length}>
+ </mr-grid-controls>
+ ${noMatches ? html`
+ <div class="empty-search">
+ Your search did not generate any results.
+ </div>` : html`
+ <progress
+ title="${Math.round(displayedProgress * 100)}%"
+ value=${displayedProgress}
+ ?hidden=${doneLoading}
+ ></progress>`}
+ <br>
+ <mr-grid
+ .issues=${this.issues}
+ .xField=${this._queryParams.x}
+ .yField=${this._queryParams.y}
+ .cellMode=${this._queryParams.cells ? this._queryParams.cells : 'tiles'}
+ .queryParams=${this._queryParams}
+ .projectName=${this.projectName}
+ ></mr-grid>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ projectName: {type: String},
+ _queryParams: {type: Object},
+ userDisplayName: {type: String},
+ issues: {type: Array},
+ fields: {type: Array},
+ progress: {type: Number},
+ totalIssues: {type: Number},
+ _presentationConfigLoaded: {type: Boolean},
+ /**
+ * The current search string the user is querying for.
+ * Project default if not specified.
+ */
+ _currentQuery: {type: String},
+ /**
+ * The current canned query the user is searching for.
+ * Project default if not specified.
+ */
+ _currentCan: {type: String},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+ this.issues = [];
+ this.progress = 0;
+ /** @type {string} */
+ this.projectName;
+ this._queryParams = {};
+ this._presentationConfigLoaded = false;
+ };
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('userDisplayName')) {
+ store.dispatch(issueV0.fetchStarredIssues());
+ }
+ // TODO(zosha): Abort sets of calls to ListIssues when
+ // queryParams.q is changed.
+ if (this._shouldFetchMatchingIssues(changedProperties)) {
+ this._fetchMatchingIssues();
+ }
+ }
+
+ /**
+ * Computes whether to fetch matching issues based on changedProperties
+ * @param {Map} changedProperties
+ * @return {boolean}
+ */
+ _shouldFetchMatchingIssues(changedProperties) {
+ const wait = shouldWaitForDefaultQuery(this._queryParams);
+ if (wait && !this._presentationConfigLoaded) {
+ return false;
+ } else if (wait && this._presentationConfigLoaded &&
+ changedProperties.has('_presentationConfigLoaded')) {
+ return true;
+ } else if (changedProperties.has('projectName') ||
+ changedProperties.has('_currentQuery') ||
+ changedProperties.has('_currentCan')) {
+ return true;
+ }
+ return false;
+ }
+
+ /** @private */
+ _fetchMatchingIssues() {
+ store.dispatch(issueV0.fetchIssueList(this.projectName, {
+ ...this._queryParams,
+ q: this._currentQuery,
+ can: this._currentCan,
+ maxItems: 500, // 500 items * 12 calls = max of 6,000 issues.
+ maxCalls: 12,
+ }));
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.projectName = projectV0.viewedProjectName(state);
+ this.issues = (issueV0.issueList(state) || []);
+ this.progress = (issueV0.issueListProgress(state) || 0);
+ this.totalIssues = (issueV0.totalIssues(state) || 0);
+ this._queryParams = sitewide.queryParams(state);
+ this._currentQuery = sitewide.currentQuery(state);
+ this._currentCan = sitewide.currentCan(state);
+ this._presentationConfigLoaded =
+ projectV0.viewedPresentationConfigLoaded(state);
+ }
+
+ /** @override */
+ static get styles() {
+ return css `
+ :host {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 0.5em 8px;
+ }
+ progress {
+ background-color: var(--chops-white);
+ border: 1px solid var(--chops-gray-500);
+ width: 40%;
+ margin-left: 1%;
+ margin-top: 0.5em;
+ visibility: visible;
+ }
+ ::-webkit-progress-bar {
+ background-color: var(--chops-white);
+ }
+ progress::-webkit-progress-value {
+ transition: width 1s;
+ background-color: var(--chops-blue-700);
+ }
+ .empty-search {
+ text-align: center;
+ padding-top: 2em;
+ }
+ `;
+ }
+};
+customElements.define('mr-grid-page', MrGridPage);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-page.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.test.js
new file mode 100644
index 0000000..241091b
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.test.js
@@ -0,0 +1,126 @@
+// 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 {MrGridPage} from './mr-grid-page.js';
+
+let element;
+
+describe('mr-grid-page', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-grid-page');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrGridPage);
+ });
+
+ it('progress bar updates properly', async () => {
+ await element.updateComplete;
+ element.progress = .2499;
+ await element.updateComplete;
+ const title =
+ element.shadowRoot.querySelector('progress').getAttribute('title');
+ assert.equal(title, '25%');
+ });
+
+ it('displays error when no issues match query', async () => {
+ await element.updateComplete;
+ element.progress = 1;
+ element.totalIssues = 0;
+ await element.updateComplete;
+ const error =
+ element.shadowRoot.querySelector('.empty-search').textContent;
+ assert.equal(error.trim(), 'Your search did not generate any results.');
+ });
+
+ it('calls to fetchIssueList made when _currentQuery changes', async () => {
+ await element.updateComplete;
+ const issueListCall = sinon.stub(element, '_fetchMatchingIssues');
+ element._queryParams = {x: 'Blocked'};
+ await element.updateComplete;
+ sinon.assert.notCalled(issueListCall);
+
+ element._presentationConfigLoaded = true;
+ element._currentQuery = 'cc:me';
+ await element.updateComplete;
+ sinon.assert.calledOnce(issueListCall);
+ });
+
+ it('calls to fetchIssueList made when _currentCan changes', async () => {
+ await element.updateComplete;
+ const issueListCall = sinon.stub(element, '_fetchMatchingIssues');
+ element._queryParams = {y: 'Blocked'};
+ await element.updateComplete;
+ sinon.assert.notCalled(issueListCall);
+
+ element._presentationConfigLoaded = true;
+ element._currentCan = 1;
+ await element.updateComplete;
+ sinon.assert.calledOnce(issueListCall);
+ });
+
+ describe('_shouldFetchMatchingIssues', () => {
+ it('default returns false', () => {
+ const result = element._shouldFetchMatchingIssues(new Map());
+ assert.isFalse(result);
+ });
+
+ it('returns true for projectName', () => {
+ element._queryParams = {q: ''};
+ const changedProps = new Map();
+ changedProps.set('projectName', 'anything');
+ const result = element._shouldFetchMatchingIssues(changedProps);
+ assert.isTrue(result);
+ });
+
+ it('returns true when _currentQuery changes', () => {
+ element._presentationConfigLoaded = true;
+
+ element._currentQuery = 'owner:me';
+ const changedProps = new Map();
+ changedProps.set('_currentQuery', '');
+
+ const result = element._shouldFetchMatchingIssues(changedProps);
+ assert.isTrue(result);
+ });
+
+ it('returns true when _currentCan changes', () => {
+ element._presentationConfigLoaded = true;
+
+ element._currentCan = 1;
+ const changedProps = new Map();
+ changedProps.set('_currentCan', 2);
+
+ const result = element._shouldFetchMatchingIssues(changedProps);
+ assert.isTrue(result);
+ });
+
+ it('returns false when presentation config not loaded', () => {
+ element._presentationConfigLoaded = false;
+
+ const changedProps = new Map();
+ changedProps.set('projectName', 'anything');
+ const result = element._shouldFetchMatchingIssues(changedProps);
+
+ assert.isFalse(result);
+ });
+
+ it('returns true when presentationConfig fetch completes', () => {
+ element._presentationConfigLoaded = true;
+
+ const changedProps = new Map();
+ changedProps.set('_presentationConfigLoaded', false);
+ const result = element._shouldFetchMatchingIssues(changedProps);
+
+ assert.isTrue(result);
+ });
+ });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.js
new file mode 100644
index 0000000..57ee474
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.js
@@ -0,0 +1,114 @@
+// 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 {issueRefToUrl, issueToIssueRef} from 'shared/convertersV0.js';
+import '../../framework/mr-star/mr-issue-star.js';
+
+/**
+ * Element for rendering a single tile in the grid view.
+ */
+export class MrGridTile extends LitElement {
+ /** @override */
+ render() {
+ return html`
+ <div class="tile-header">
+ <mr-issue-star
+ .issueRef=${this.issueRef}
+ ></mr-issue-star>
+ <a class="issue-id" href=${issueRefToUrl(this.issue, this.queryParams)}>
+ ${this.issue.localId}
+ </a>
+ <div class="status">
+ ${this.issue.statusRef ? this.issue.statusRef.status : ''}
+ </div>
+ </div>
+ <div class="summary">
+ ${this.issue.summary || ''}
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issue: {type: Object},
+ issueRef: {type: Object},
+ queryParams: {type: Object},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+ this.issue = {};
+ this.queryParams = '';
+ };
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('issue')) {
+ this.issueRef = issueToIssueRef(this.issue);
+ }
+ super.update(changedProperties);
+ }
+
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ border: 2px solid var(--chops-gray-200);
+ border-radius: 6px;
+ padding: 1px;
+ margin: 3px;
+ background: var(--chops-white);
+ width: 10em;
+ height: 5em;
+ float: left;
+ table-layout: fixed;
+ overflow: hidden;
+ }
+ :host(:hover) {
+ border-color: var(--chops-blue-100);
+ }
+ .tile-header {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ margin-bottom: 0.1em;
+ }
+ mr-issue-star {
+ --mr-star-size: 16px;
+ }
+ a.issue-id {
+ font-weight: 500;
+ text-decoration: none;
+ display: inline-block;
+ padding-left: .25em;
+ color: var(--chops-blue-700);
+ }
+ .status {
+ display: inline-block;
+ font-size: 90%;
+ max-width: 30%;
+ white-space: nowrap;
+ padding-left: 4px;
+ }
+ .summary {
+ height: 3.7em;
+ font-size: 90%;
+ line-height: 94%;
+ padding: .05px .25em .05px .25em;
+ position: relative;
+ }
+ a:hover {
+ text-decoration: underline;
+ }
+ `;
+ };
+};
+
+customElements.define('mr-grid-tile', MrGridTile);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.test.js
new file mode 100644
index 0000000..c9577c6
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.test.js
@@ -0,0 +1,56 @@
+// 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 {MrGridTile} from './mr-grid-tile.js';
+
+let element;
+const summary = 'Testing summary of an issue.';
+const testIssue = {
+ projectName: 'Monorail',
+ localId: '2345',
+ summary: summary,
+};
+
+describe('mr-grid-tile', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-grid-tile');
+ element.issue = testIssue;
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrGridTile);
+ });
+
+ it('properly links', async () => {
+ await element.updateComplete;
+ const tileLink = element.shadowRoot.querySelector('a').getAttribute('href');
+ assert.equal(tileLink, `/p/Monorail/issues/detail?id=2345`);
+ });
+
+ it('summary displays', async () => {
+ await element.updateComplete;
+ const tileSummary =
+ element.shadowRoot.querySelector('.summary').textContent;
+ assert.equal(tileSummary.trim(), summary);
+ });
+
+ it('status displays', async () => {
+ await element.updateComplete;
+ const tileStatus =
+ element.shadowRoot.querySelector('.status').textContent;
+ assert.equal(tileStatus.trim(), '');
+ });
+
+ it('id displays', async () => {
+ await element.updateComplete;
+ const tileId =
+ element.shadowRoot.querySelector('.issue-id').textContent;
+ assert.equal(tileId.trim(), '2345');
+ });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid.js b/static_src/elements/issue-list/mr-grid-page/mr-grid.js
new file mode 100644
index 0000000..f459489
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid.js
@@ -0,0 +1,291 @@
+// 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 './mr-grid-tile.js';
+
+import {css, html, LitElement} from 'lit-element';
+import qs from 'qs';
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+import {setHasAny} from 'shared/helpers.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import {extractGridData, makeGridCellKey} from './extract-grid-data.js';
+
+const PROPERTIES_TRIGGERING_GROUPING = Object.freeze([
+ 'xField',
+ 'yField',
+ 'issues',
+ '_extractFieldValuesFromIssue',
+ '_extractTypeForFieldName',
+ '_statusDefs',
+]);
+
+/**
+ * <mr-grid>
+ *
+ * A grid of issues grouped optionally horizontally and vertically.
+ *
+ * Throughout the file 'x' corresponds to column headers and 'y' corresponds to
+ * row headers.
+ *
+ * @extends {LitElement}
+ */
+export class MrGrid extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <table>
+ <tr>
+ <th> </th>
+ ${this._xHeadings.map((heading) => html`
+ <th>${heading}</th>`)}
+ </tr>
+ ${this._yHeadings.map((yHeading) => html`
+ <tr>
+ <th>${yHeading}</th>
+ ${this._xHeadings.map((xHeading) => html`
+ ${this._renderCell(xHeading, yHeading)}`)}
+ </tr>
+ `)}
+ </table>
+ `;
+ }
+ /**
+ *
+ * @param {string} xHeading
+ * @param {string} yHeading
+ * @return {TemplateResult}
+ */
+ _renderCell(xHeading, yHeading) {
+ const cell = this._groupedIssues.get(makeGridCellKey(xHeading, yHeading));
+ if (!cell) {
+ return html`<td></td>`;
+ }
+
+ const cellMode = this.cellMode.toLowerCase();
+ let content;
+ if (cellMode === 'ids') {
+ content = html`
+ ${cell.map((issue) => html`
+ <mr-issue-link
+ .projectName=${this.projectName}
+ .issue=${issue}
+ .text=${issue.localId}
+ .queryParams=${this.queryParams}
+ ></mr-issue-link>
+ `)}
+ `;
+ } else if (cellMode === 'counts') {
+ const itemCount = cell.length;
+ if (itemCount === 1) {
+ const issue = cell[0];
+ content = html`
+ <a href=${issueRefToUrl(issue, this.queryParams)} class="counts">
+ 1 item
+ </a>
+ `;
+ } else {
+ content = html`
+ <a href=${this._formatListUrl(xHeading, yHeading)} class="counts">
+ ${itemCount} items
+ </a>
+ `;
+ }
+ } else {
+ // Default to tiles.
+ content = html`
+ ${cell.map((issue) => html`
+ <mr-grid-tile
+ .issue=${issue}
+ .queryParams=${this.queryParams}
+ ></mr-grid-tile>
+ `)}
+ `;
+ }
+ return html`<td>${content}</td>`;
+ }
+
+ /**
+ * Creates a URL to the list view for the group of issues corresponding to
+ * the given headings.
+ *
+ * @param {string} xHeading
+ * @param {string} yHeading
+ * @return {string}
+ */
+ _formatListUrl(xHeading, yHeading) {
+ let url = 'list?';
+ const params = Object.assign({}, this.queryParams);
+ params.mode = '';
+
+ params.q = this._addHeadingToQuery(params.q, xHeading, this.xField);
+ params.q = this._addHeadingToQuery(params.q, yHeading, this.yField);
+
+ url += qs.stringify(params);
+
+ return url;
+ }
+
+ /**
+ * @param {string} query
+ * @param {string} heading The value of field for the current group.
+ * @param {string} field Field on which we're grouping the issue.
+ * @return {string} The query with an additional clause if needed.
+ */
+ _addHeadingToQuery(query, heading, field) {
+ if (field && field !== 'None') {
+ if (heading === EMPTY_FIELD_VALUE) {
+ query += ' -has:' + field;
+ // The following two cases are to handle grouping issues by Blocked
+ } else if (heading === 'No') {
+ query += ' -is:' + field;
+ } else if (heading === 'Yes') {
+ query += ' is:' + field;
+ } else {
+ query += ' ' + field + '=' + heading;
+ }
+ }
+ return query;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ xField: {type: String},
+ yField: {type: String},
+ issues: {type: Array},
+ cellMode: {type: String},
+ queryParams: {type: Object},
+ projectName: {type: String},
+ _extractFieldValuesFromIssue: {type: Object},
+ _extractTypeForFieldName: {type: Object},
+ _statusDefs: {type: Array},
+ _labelPrefixValueMap: {type: Map},
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ table {
+ table-layout: auto;
+ border-collapse: collapse;
+ width: 98%;
+ margin: 0.5em 1%;
+ text-align: left;
+ }
+ th {
+ border: 1px solid white;
+ padding: 5px;
+ background-color: var(--chops-table-header-bg);
+ white-space: nowrap;
+ }
+ td {
+ border: var(--chops-table-divider);
+ padding-left: 0.3em;
+ background-color: var(--chops-white);
+ vertical-align: top;
+ }
+ mr-issue-link {
+ display: inline-block;
+ margin-right: 8px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ /** @type {string} */
+ this.cellMode = 'tiles';
+ /** @type {Array<Issue>} */
+ this.issues = [];
+ /** @type {string} */
+ this.projectName;
+ this.queryParams = {};
+
+ /** @type {string} The issue field on which to group columns. */
+ this.xField;
+
+ /** @type {string} The issue field on which to group rows. */
+ this.yField;
+
+ /**
+ * Grid cell key mapped to issues associated with that cell.
+ * @type {Map.<string, Array<Issue>>}
+ */
+ this._groupedIssues = new Map();
+
+ /** @type {Array<string>} */
+ this._xHeadings = [];
+
+ /** @type {Array<string>} */
+ this._yHeadings = [];
+
+ /**
+ * Method for extracting values from an issue for a given
+ * project config.
+ * @type {function(Issue, string): Array<string>}
+ */
+ this._extractFieldValuesFromIssue = undefined;
+
+ /**
+ * Method for finding the types of fields based on their names.
+ * @type {function(string): string}
+ */
+ this._extractTypeForFieldName = undefined;
+
+ /**
+ * Note: no default assigned here: it can be undefined in stateChanged.
+ * @type {Array<StatusDef>}
+ */
+ this._statusDefs;
+
+ /**
+ * Mapping predefined label prefix to set of values
+ * @type {Map}
+ */
+ this._labelPrefixValueMap = new Map();
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._extractFieldValuesFromIssue =
+ projectV0.extractFieldValuesFromIssue(state);
+ this._extractTypeForFieldName = projectV0.extractTypeForFieldName(state);
+ this._statusDefs = projectV0.viewedConfig(state).statusDefs;
+ this._labelPrefixValueMap = projectV0.labelPrefixValueMap(state);
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (setHasAny(changedProperties, PROPERTIES_TRIGGERING_GROUPING)) {
+ if (this._extractFieldValuesFromIssue) {
+ const gridData = extractGridData({
+ issues: this.issues,
+ extractFieldValuesFromIssue: this._extractFieldValuesFromIssue,
+ }, {
+ xFieldName: this.xField,
+ yFieldName: this.yField,
+ extractTypeForFieldName: this._extractTypeForFieldName,
+ statusDefs: this._statusDefs,
+ labelPrefixValueMap: this._labelPrefixValueMap,
+ });
+
+ this._xHeadings = gridData.xHeadings;
+ this._yHeadings = gridData.yHeadings;
+ this._groupedIssues = gridData.groupedIssues;
+ }
+ }
+
+ super.update(changedProperties);
+ }
+};
+customElements.define('mr-grid', MrGrid);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid.test.js
new file mode 100644
index 0000000..eb430de
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid.test.js
@@ -0,0 +1,214 @@
+// 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 {MrGrid} from './mr-grid.js';
+import {MrIssueLink} from
+ 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+
+let element;
+
+describe('mr-grid', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-grid');
+ element.queryParams = {x: '', y: ''};
+ element.issues = [{localId: 1, projectName: 'monorail'}];
+ element.projectName = 'monorail';
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrGrid);
+ });
+
+ it('renders issues in ID mode', async () => {
+ element.cellMode = 'IDs';
+
+ await element.updateComplete;
+
+ assert.instanceOf(element.shadowRoot.querySelector(
+ 'mr-issue-link'), MrIssueLink);
+ });
+
+ it('renders one issue in counts mode', async () => {
+ element.cellMode = 'Counts';
+
+ await element.updateComplete;
+
+ const href = element.shadowRoot.querySelector('.counts').href;
+ assert.include(href, '/p/monorail/issues/detail?id=1&x=&y=');
+ });
+
+ it('renders as tiles when invalid cell mode set', async () => {
+ element.cellMode = 'InvalidCells';
+
+ await element.updateComplete;
+
+ const tile = element.shadowRoot.querySelector('mr-grid-tile');
+ assert.isDefined(tile);
+ assert.deepEqual(tile.issue, {localId: 1, projectName: 'monorail'});
+ });
+
+ it('groups issues before rendering', async () => {
+ const testIssue = {
+ localId: 1,
+ projectName: 'monorail',
+ starCount: 2,
+ blockedOnIssueRefs: [{localId: 22, projectName: 'chromium'}],
+ };
+
+ element.cellMode = 'Tiles';
+
+ element.issues = [testIssue];
+ element.xField = 'Stars';
+ element.yField = 'Blocked';
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._groupedIssues, new Map([
+ ['2 + Yes', [testIssue]],
+ ]));
+
+ const rows = element.shadowRoot.querySelectorAll('tr');
+
+ const colHeader = rows[0].querySelectorAll('th')[1];
+ assert.equal(colHeader.textContent.trim(), '2');
+
+ const rowHeader = rows[1].querySelector('th');
+ assert.equal(rowHeader.textContent.trim(), 'Yes');
+
+ const issueCell = rows[1].querySelector('td');
+ const tile = issueCell.querySelector('mr-grid-tile');
+
+ assert.isDefined(tile);
+ assert.deepEqual(tile.issue, testIssue);
+ });
+
+ it('renders status groups in statusDef order', async () => {
+ element._statusDefs = [
+ {status: 'UltraNew'},
+ {status: 'New'},
+ {status: 'Accepted'},
+ ];
+
+ element.issues = [
+ {localId: 2, projectName: 'monorail', statusRef: {status: 'New'}},
+ {localId: 4, projectName: 'monorail', statusRef: {status: 'Accepted'}},
+ {localId: 3, projectName: 'monorail', statusRef: {status: 'New'}},
+ {localId: 1, projectName: 'monorail', statusRef: {status: 'UltraNew'}},
+ ];
+
+ element.cellMode = 'IDs';
+ element.xField = 'Status';
+ element.yField = '';
+
+ await element.updateComplete;
+
+ const rows = element.shadowRoot.querySelectorAll('tr');
+
+ const colHeaders = rows[0].querySelectorAll('th');
+ assert.equal(colHeaders[1].textContent.trim(), 'UltraNew');
+ assert.equal(colHeaders[2].textContent.trim(), 'New');
+ assert.equal(colHeaders[3].textContent.trim(), 'Accepted');
+
+ const issueCells = rows[1].querySelectorAll('td');
+
+ const ultraNewIssues = issueCells[0].querySelectorAll('mr-issue-link');
+ assert.equal(ultraNewIssues.length, 1);
+
+ const newIssues = issueCells[1].querySelectorAll('mr-issue-link');
+ assert.equal(newIssues.length, 2);
+
+ const acceptedIssues = issueCells[2].querySelectorAll('mr-issue-link');
+ assert.equal(acceptedIssues.length, 1);
+ });
+
+ it('computes href for multiple items in counts mode', async () => {
+ element.cellMode = 'Counts';
+
+ element.issues = [
+ {localId: 1, projectName: 'monorail'},
+ {localId: 2, projectName: 'monorail'},
+ ];
+
+ await element.updateComplete;
+
+ const href = element.shadowRoot.querySelector('.counts').href;
+ assert.include(href, '/list?x=&y=&mode=');
+ });
+
+ it('computes list link when grouped by row in counts mode', async () => {
+ await element.updateComplete;
+
+ element.cellMode = 'Counts';
+ element.queryParams = {x: 'Type', y: '', q: 'Type:Defect'};
+ element._xHeadings = ['All', 'Defect'];
+ element._yHeadings = ['All'];
+ element._groupedIssues = new Map([
+ ['All + All', [{'localId': 1, 'projectName': 'monorail'}]],
+ ['Defect + All', [
+ {localId: 2, projectName: 'monorail',
+ labelRefs: [{label: 'Type-Defect'}]},
+ {localId: 3, projectName: 'monorail',
+ labelRefs: [{label: 'Type-Defect'}]},
+ ]],
+ ]);
+
+ await element.updateComplete;
+
+ const href = element.shadowRoot.querySelectorAll('.counts')[1].href;
+ assert.include(href, '/list?x=Type&y=&q=Type%3ADefect&mode=');
+ });
+
+ it('computes list link when grouped by col in counts mode', async () => {
+ await element.updateComplete;
+
+ element.cellMode = 'Counts';
+ element.queryParams = {x: '', y: 'Type', q: 'Type:Defect'};
+ element._xHeadings = ['All'];
+ element._yHeadings = ['All', 'Defect'];
+ element._groupedIssues = new Map([
+ ['All + All', [{'localId': 1, 'projectName': 'monorail'}]],
+ ['All + Defect', [
+ {localId: 2, projectName: 'monorail',
+ labelRefs: [{label: 'Type-Defect'}]},
+ {localId: 3, projectName: 'monorail',
+ labelRefs: [{label: 'Type-Defect'}]},
+ ]],
+ ]);
+
+ await element.updateComplete;
+
+ const href = element.shadowRoot.querySelectorAll('.counts')[1].href;
+ assert.include(href, '/list?x=&y=Type&q=Type%3ADefect&mode=');
+ });
+
+ it('computes list link when grouped by row, col in counts mode', async () => {
+ await element.updateComplete;
+
+ element.cellMode = 'Counts';
+ element.queryParams = {x: 'Stars', y: 'Type',
+ q: 'Type:Defect Stars=2'};
+ element._xHeadings = ['All', '2'];
+ element._yHeadings = ['All', 'Defect'];
+ element._groupedIssues = new Map([
+ ['All + All', [{'localId': 1, 'projectName': 'monorail'}]],
+ ['2 + Defect', [
+ {localId: 2, projectName: 'monorail',
+ labelRefs: [{label: 'Type-Defect'}], starCount: 2},
+ {localId: 3, projectName: 'monorail',
+ labelRefs: [{label: 'Type-Defect'}], starCount: 2},
+ ]],
+ ]);
+
+ await element.updateComplete;
+
+ const href = element.shadowRoot.querySelectorAll('.counts')[1].href;
+ assert.include(href,
+ '/list?x=Stars&y=Type&q=Type%3ADefect%20Stars%3D2&mode=');
+ });
+});
diff --git a/static_src/elements/issue-list/mr-list-page/mr-list-page.js b/static_src/elements/issue-list/mr-list-page/mr-list-page.js
new file mode 100644
index 0000000..809c3fc
--- /dev/null
+++ b/static_src/elements/issue-list/mr-list-page/mr-list-page.js
@@ -0,0 +1,662 @@
+// 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 page from 'page';
+import qs from 'qs';
+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 * as ui from 'reducers/ui.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
+import {DEFAULT_ISSUE_FIELD_LIST, parseColSpec} from 'shared/issue-fields.js';
+import {
+ shouldWaitForDefaultQuery,
+ urlWithNewParams,
+ userIsMember,
+} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'elements/framework/dialogs/mr-change-columns/mr-change-columns.js';
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
+import 'elements/framework/mr-button-bar/mr-button-bar.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/mr-issue-list/mr-issue-list.js';
+import '../mr-mode-selector/mr-mode-selector.js';
+
+export const DEFAULT_ISSUES_PER_PAGE = 100;
+const PARAMS_THAT_TRIGGER_REFRESH = ['sort', 'groupby', 'num',
+ 'start'];
+const SNACKBAR_LOADING = 'Loading issues...';
+
+/**
+ * `<mr-list-page>`
+ *
+ * Container page for the list view
+ */
+export class MrListPage extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 0.5em 8px;
+ }
+ .container-loading,
+ .container-no-issues {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0 8px;
+ font-size: var(--chops-main-font-size);
+ }
+ .container-no-issues {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+ .container-no-issues p {
+ margin: 0.5em;
+ }
+ .no-issues-block {
+ display: block;
+ padding: 1em 16px;
+ margin-top: 1em;
+ flex-grow: 1;
+ width: 300px;
+ max-width: 100%;
+ text-align: center;
+ background: var(--chops-primary-accent-bg);
+ border-radius: 8px;
+ border-bottom: var(--chops-normal-border);
+ }
+ .no-issues-block[hidden] {
+ display: none;
+ }
+ .list-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 0.5em 0;
+ }
+ .right-controls {
+ flex-grow: 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ }
+ .next-link, .prev-link {
+ display: inline-block;
+ margin: 0 8px;
+ }
+ mr-mode-selector {
+ margin-left: 8px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ const selectedRefs = this.selectedIssues.map(
+ ({localId, projectName}) => ({localId, projectName}));
+
+ return html`
+ ${this._renderControls()}
+ ${this._renderListBody()}
+ <mr-update-issue-hotlists-dialog
+ .issueRefs=${selectedRefs}
+ @saveSuccess=${this._showHotlistSaveSnackbar}
+ ></mr-update-issue-hotlists-dialog>
+ <mr-change-columns
+ .columns=${this.columns}
+ .queryParams=${this._queryParams}
+ ></mr-change-columns>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderListBody() {
+ if (!this._issueListLoaded) {
+ return html`
+ <div class="container-loading">
+ Loading...
+ </div>
+ `;
+ } else if (!this.totalIssues) {
+ return html`
+ <div class="container-no-issues">
+ <p>
+ The search query:
+ </p>
+ <strong>${this._queryParams.q}</strong>
+ <p>
+ did not generate any results.
+ </p>
+ <div class="no-issues-block">
+ Type a new query in the search box above
+ </div>
+ <a
+ href=${this._urlWithNewParams({can: 2, q: ''})}
+ class="no-issues-block view-all-open"
+ >
+ View all open issues
+ </a>
+ <a
+ href=${this._urlWithNewParams({can: 1})}
+ class="no-issues-block consider-closed"
+ ?hidden=${this._queryParams.can === '1'}
+ >
+ Consider closed issues
+ </a>
+ </div>
+ `;
+ }
+
+ return html`
+ <mr-issue-list
+ .issues=${this.issues}
+ .projectName=${this.projectName}
+ .queryParams=${this._queryParams}
+ .initialCursor=${this._queryParams.cursor}
+ .currentQuery=${this.currentQuery}
+ .currentCan=${this.currentCan}
+ .columns=${this.columns}
+ .defaultFields=${DEFAULT_ISSUE_FIELD_LIST}
+ .extractFieldValues=${this._extractFieldValues}
+ .groups=${this.groups}
+ .userDisplayName=${this.userDisplayName}
+ ?selectionEnabled=${this.editingEnabled}
+ ?sortingAndGroupingEnabled=${true}
+ ?starringEnabled=${this.starringEnabled}
+ @selectionChange=${this._setSelectedIssues}
+ ></mr-issue-list>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderControls() {
+ const maxItems = this.maxItems;
+ const startIndex = this.startIndex;
+ const end = Math.min(startIndex + maxItems, this.totalIssues);
+ const hasNext = end < this.totalIssues;
+ const hasPrev = startIndex > 0;
+
+ return html`
+ <div class="list-controls">
+ <div>
+ ${this.editingEnabled ? html`
+ <mr-button-bar .items=${this._actions}></mr-button-bar>
+ ` : ''}
+ </div>
+
+ <div class="right-controls">
+ ${hasPrev ? html`
+ <a
+ href=${this._urlWithNewParams({start: startIndex - maxItems})}
+ class="prev-link"
+ >
+ ‹ Prev
+ </a>
+ ` : ''}
+ <div class="issue-count" ?hidden=${!this.totalIssues}>
+ ${startIndex + 1} - ${end} of ${this.totalIssuesDisplay}
+ </div>
+ ${hasNext ? html`
+ <a
+ href=${this._urlWithNewParams({start: startIndex + maxItems})}
+ class="next-link"
+ >
+ Next ›
+ </a>
+ ` : ''}
+ <mr-mode-selector
+ .projectName=${this.projectName}
+ .queryParams=${this._queryParams}
+ value="list"
+ ></mr-mode-selector>
+ </div>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issues: {type: Array},
+ totalIssues: {type: Number},
+ /** @private {Object} */
+ _queryParams: {type: Object},
+ projectName: {type: String},
+ _fetchingIssueList: {type: Boolean},
+ _issueListLoaded: {type: Boolean},
+ selectedIssues: {type: Array},
+ columns: {type: Array},
+ userDisplayName: {type: String},
+ /**
+ * The current search string the user is querying for.
+ */
+ currentQuery: {type: String},
+ /**
+ * The current canned query the user is searching for.
+ */
+ currentCan: {type: String},
+ /**
+ * A function that takes in an issue and a field name and returns the
+ * value for that field in the issue. This function accepts custom fields,
+ * built in fields, and ad hoc fields computed from label prefixes.
+ */
+ _extractFieldValues: {type: Object},
+ _isLoggedIn: {type: Boolean},
+ _currentUser: {type: Object},
+ _usersProjects: {type: Object},
+ _fetchIssueListError: {type: String},
+ _presentationConfigLoaded: {type: Boolean},
+ };
+ };
+
+ /** @override */
+ constructor() {
+ super();
+ this.issues = [];
+ this._fetchingIssueList = false;
+ this._issueListLoaded = false;
+ this.selectedIssues = [];
+ this._queryParams = {};
+ this.columns = [];
+ this._usersProjects = new Map();
+ this._presentationConfigLoaded = false;
+
+ this._boundRefresh = this.refresh.bind(this);
+
+ this._actions = [
+ {icon: 'edit', text: 'Bulk edit', handler: this.bulkEdit.bind(this)},
+ {
+ icon: 'add', text: 'Add to hotlist',
+ handler: this.addToHotlist.bind(this),
+ },
+ {
+ icon: 'table_chart', text: 'Change columns',
+ handler: this.openColumnsDialog.bind(this),
+ },
+ {icon: 'more_vert', text: 'More actions...', items: [
+ {text: 'Flag as spam', handler: () => this._flagIssues(true)},
+ {text: 'Un-flag as spam', handler: () => this._flagIssues(false)},
+ ]},
+ ];
+
+ /**
+ * @param {Issue} _issue
+ * @param {string} _fieldName
+ * @return {Array<string>}
+ */
+ this._extractFieldValues = (_issue, _fieldName) => [];
+
+ // Expose page.js for test stubbing.
+ this.page = page;
+ };
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ window.addEventListener('refreshList', this._boundRefresh);
+
+ // TODO(zhangtiff): Consider if we can make this page title more useful for
+ // the list view.
+ store.dispatch(sitewide.setPageTitle('Issues'));
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ window.removeEventListener('refreshList', this._boundRefresh);
+
+ this._hideIssueLoadingSnackbar();
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ this._measureIssueListLoadTime(changedProperties);
+
+ if (changedProperties.has('_fetchingIssueList')) {
+ const wasFetching = changedProperties.get('_fetchingIssueList');
+ const isFetching = this._fetchingIssueList;
+ // Show a snackbar if waiting for issues to load but only when there's
+ // already a different, non-empty issue list loaded. This approach avoids
+ // clearing the issue list for a loading screen.
+ if (isFetching && this.totalIssues > 0) {
+ this._showIssueLoadingSnackbar();
+ }
+ if (wasFetching && !isFetching) {
+ this._hideIssueLoadingSnackbar();
+ }
+ }
+
+ if (changedProperties.has('userDisplayName')) {
+ store.dispatch(issueV0.fetchStarredIssues());
+ }
+
+ if (changedProperties.has('_fetchIssueListError') &&
+ this._fetchIssueListError) {
+ this._showIssueErrorSnackbar(this._fetchIssueListError);
+ }
+
+ const shouldRefresh = this._shouldRefresh(changedProperties);
+ if (shouldRefresh) this.refresh();
+ }
+
+ /**
+ * Tracks the start and end times of an issues list render and
+ * records an issue list load time.
+ * @param {Map} changedProperties
+ */
+ async _measureIssueListLoadTime(changedProperties) {
+ if (!changedProperties.has('issues')) {
+ return;
+ }
+
+ if (!changedProperties.get('issues')) {
+ // Ignore initial initialization from the constructer where
+ // 'issues' is set from undefined to an empty array.
+ return;
+ }
+
+ const fullAppLoad = ui.navigationCount(store.getState()) == 1;
+ const startMark = fullAppLoad ? undefined : 'start load issue list page';
+
+ await Promise.all(_subtreeUpdateComplete(this));
+
+ const endMark = 'finish load list of issues';
+ performance.mark(endMark);
+
+ const measurementType = fullAppLoad ? 'from outside app' : 'within app';
+ const measurementName = `load list of issues (${measurementType})`;
+ performance.measure(measurementName, startMark, endMark);
+
+ const measurement = performance.getEntriesByName(
+ measurementName)[0].duration;
+ window.getTSMonClient().recordIssueListLoadTiming(measurement, fullAppLoad);
+
+ // Be sure to clear this mark even on full page navigation.
+ performance.clearMarks('start load issue list page');
+ performance.clearMarks(endMark);
+ performance.clearMeasures(measurementName);
+ }
+
+ /**
+ * Considers if list-page should fetch ListIssues
+ * @param {Map} changedProperties
+ * @return {boolean}
+ */
+ _shouldRefresh(changedProperties) {
+ const wait = shouldWaitForDefaultQuery(this._queryParams);
+ if (wait && !this._presentationConfigLoaded) {
+ return false;
+ } else if (wait && this._presentationConfigLoaded &&
+ changedProperties.has('_presentationConfigLoaded')) {
+ return true;
+ } else if (changedProperties.has('projectName') ||
+ changedProperties.has('currentQuery') ||
+ changedProperties.has('currentCan')) {
+ return true;
+ } else if (changedProperties.has('_queryParams')) {
+ const oldParams = changedProperties.get('_queryParams') || {};
+
+ const shouldRefresh = PARAMS_THAT_TRIGGER_REFRESH.some((param) => {
+ const oldValue = oldParams[param];
+ const newValue = this._queryParams[param];
+ return oldValue !== newValue;
+ });
+ return shouldRefresh;
+ }
+ return false;
+ }
+
+ // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
+ /** Dispatches a Redux action to show an issues loading snackbar. */
+ _showIssueLoadingSnackbar() {
+ store.dispatch(ui.showSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST,
+ SNACKBAR_LOADING, 0));
+ }
+
+ /** Dispatches a Redux action to hide the issue loading snackbar. */
+ _hideIssueLoadingSnackbar() {
+ store.dispatch(ui.hideSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST));
+ }
+
+ /**
+ * Shows a snackbar telling the user their issue loading failed.
+ * @param {string} error The error to display.
+ */
+ _showIssueErrorSnackbar(error) {
+ store.dispatch(ui.showSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST_ERROR,
+ error));
+ }
+
+ /**
+ * Refreshes the list of issues show.
+ */
+ refresh() {
+ store.dispatch(issueV0.fetchIssueList(this.projectName, {
+ ...this._queryParams,
+ q: this.currentQuery,
+ can: this.currentCan,
+ maxItems: this.maxItems,
+ start: this.startIndex,
+ }));
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.projectName = projectV0.viewedProjectName(state);
+ this._isLoggedIn = userV0.isLoggedIn(state);
+ this._currentUser = userV0.currentUser(state);
+ this._usersProjects = userV0.projectsPerUser(state);
+
+ this.issues = issueV0.issueList(state) || [];
+ this.totalIssues = issueV0.totalIssues(state) || 0;
+ this._fetchingIssueList = issueV0.requests(state).fetchIssueList.requesting;
+ this._issueListLoaded = issueV0.issueListLoaded(state);
+
+ const error = issueV0.requests(state).fetchIssueList.error;
+ this._fetchIssueListError = error ? error.message : '';
+
+ this.currentQuery = sitewide.currentQuery(state);
+ this.currentCan = sitewide.currentCan(state);
+ this.columns =
+ sitewide.currentColumns(state) || projectV0.defaultColumns(state);
+
+ this._queryParams = sitewide.queryParams(state);
+
+ this._extractFieldValues = projectV0.extractFieldValuesFromIssue(state);
+ this._presentationConfigLoaded =
+ projectV0.viewedPresentationConfigLoaded(state);
+ }
+
+ /**
+ * @return {string} Display text of total issue number.
+ */
+ get totalIssuesDisplay() {
+ if (this.totalIssues === 1) {
+ return `${this.totalIssues}`;
+ } else if (this.totalIssues === SERVER_LIST_ISSUES_LIMIT) {
+ // Server has hard limit up to 100,000 list results
+ return `100,000+`;
+ }
+ return `${this.totalIssues}`;
+ }
+
+ /**
+ * @return {boolean} Whether the user is able to star the issues in the list.
+ */
+ get starringEnabled() {
+ return this._isLoggedIn;
+ }
+
+ /**
+ * @return {boolean} Whether the user has permissions to edit the issues in
+ * the list.
+ */
+ get editingEnabled() {
+ return this._isLoggedIn && (userIsMember(this._currentUser,
+ this.projectName, this._usersProjects) ||
+ this._currentUser.isSiteAdmin);
+ }
+
+ /**
+ * @return {Array<string>} Array of columns to group by.
+ */
+ get groups() {
+ return parseColSpec(this._queryParams.groupby);
+ }
+
+ /**
+ * @return {number} Maximum number of issues to load for this query.
+ */
+ get maxItems() {
+ return Number.parseInt(this._queryParams.num) || DEFAULT_ISSUES_PER_PAGE;
+ }
+
+ /**
+ * @return {number} Number of issues to offset by, based on pagination.
+ */
+ get startIndex() {
+ const num = Number.parseInt(this._queryParams.start) || 0;
+ return Math.max(0, num);
+ }
+
+ /**
+ * Computes the current URL of the page with updated queryParams.
+ *
+ * @param {Object} newParams keys and values to override existing parameters.
+ * @return {string} the new URL.
+ */
+ _urlWithNewParams(newParams) {
+ const baseUrl = `/p/${this.projectName}/issues/list`;
+ return urlWithNewParams(baseUrl, this._queryParams, newParams);
+ }
+
+ /**
+ * Shows the user an alert telling them their action won't work.
+ * @param {string} action Text describing what you're trying to do.
+ */
+ noneSelectedAlert(action) {
+ // TODO(zhangtiff): Replace this with a modal for a more modern feel.
+ alert(`Please select some issues to ${action}.`);
+ }
+
+ /**
+ * Opens the the column selector.
+ */
+ openColumnsDialog() {
+ this.shadowRoot.querySelector('mr-change-columns').open();
+ }
+
+ /**
+ * Opens a modal to add the selected issues to a hotlist.
+ */
+ addToHotlist() {
+ const issues = this.selectedIssues;
+ if (!issues || !issues.length) {
+ this.noneSelectedAlert('add to hotlists');
+ return;
+ }
+ this.shadowRoot.querySelector('mr-update-issue-hotlists-dialog').open();
+ }
+
+ /**
+ * Redirects the user to the bulk edit page for the issues they've selected.
+ */
+ bulkEdit() {
+ const issues = this.selectedIssues;
+ if (!issues || !issues.length) {
+ this.noneSelectedAlert('edit');
+ return;
+ }
+ const params = {
+ ids: issues.map((issue) => issue.localId).join(','),
+ q: this._queryParams && this._queryParams.q,
+ };
+ this.page(`/p/${this.projectName}/issues/bulkedit?${qs.stringify(params)}`);
+ }
+
+ /** Shows user confirmation that their hotlist changes were saved. */
+ _showHotlistSaveSnackbar() {
+ store.dispatch(ui.showSnackbar(ui.snackbarNames.UPDATE_HOTLISTS_SUCCESS,
+ 'Hotlists updated.'));
+ }
+
+ /**
+ * Flags the selected issues as spam.
+ * @param {boolean} flagAsSpam If true, flag as spam. If false, unflag
+ * as spam.
+ */
+ async _flagIssues(flagAsSpam = true) {
+ const issues = this.selectedIssues;
+ if (!issues || !issues.length) {
+ return this.noneSelectedAlert(
+ `${flagAsSpam ? 'flag' : 'un-flag'} as spam`);
+ }
+ const refs = issues.map((issue) => ({
+ localId: issue.localId,
+ projectName: issue.projectName,
+ }));
+
+ // TODO(zhangtiff): Refactor this into a shared action creator and
+ // display the error on the frontend.
+ try {
+ await prpcClient.call('monorail.Issues', 'FlagIssues', {
+ issueRefs: refs,
+ flag: flagAsSpam,
+ });
+ this.refresh();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ /**
+ * Syncs this component's selected issues with the child component's selected
+ * issues.
+ */
+ _setSelectedIssues() {
+ const issueListRef = this.shadowRoot.querySelector('mr-issue-list');
+ if (!issueListRef) return;
+
+ this.selectedIssues = issueListRef.selectedIssues;
+ }
+};
+
+
+/**
+ * Recursively traverses all shadow DOMs in an element subtree and returns an
+ * Array containing the updateComplete Promises for all lit-element nodes.
+ * @param {!LitElement} element
+ * @return {!Array<Promise<Boolean>>}
+ */
+function _subtreeUpdateComplete(element) {
+ if (!(element.shadowRoot && element.updateComplete)) {
+ return [];
+ }
+
+ const children = element.shadowRoot.querySelectorAll('*');
+ const childPromises = Array.from(children, (e) => _subtreeUpdateComplete(e));
+ return [element.updateComplete].concat(...childPromises);
+}
+
+customElements.define('mr-list-page', MrListPage);
diff --git a/static_src/elements/issue-list/mr-list-page/mr-list-page.test.js b/static_src/elements/issue-list/mr-list-page/mr-list-page.test.js
new file mode 100644
index 0000000..0f1d4ac
--- /dev/null
+++ b/static_src/elements/issue-list/mr-list-page/mr-list-page.test.js
@@ -0,0 +1,615 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrListPage, DEFAULT_ISSUES_PER_PAGE} from './mr-list-page.js';
+import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
+import {store, resetState} from 'reducers/base.js';
+
+let element;
+
+describe('mr-list-page', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ element = document.createElement('mr-list-page');
+ document.body.appendChild(element);
+ sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrListPage);
+ });
+
+ it('shows loading page when issues not loaded yet', async () => {
+ element._issueListLoaded = false;
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.equal(loading.textContent.trim(), 'Loading...');
+ assert.isNull(noIssues);
+ assert.isNull(issueList);
+ });
+
+ it('does not clear existing issue list when loading new issues', async () => {
+ element._fetchingIssueList = true;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 1;
+ element.issues = [{localId: 1, projectName: 'chromium'}];
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.isNull(loading);
+ assert.isNull(noIssues);
+ assert.isNotNull(issueList);
+ // TODO(crbug.com/monorail/6560): We intend for the snackbar to be shown,
+ // but it is hidden because the store thinks we have 0 total issues.
+ });
+
+ it('shows list when done loading', async () => {
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.isNull(loading);
+ assert.isNull(noIssues);
+ assert.isNotNull(issueList);
+ });
+
+ describe('issue loading snackbar', () => {
+ beforeEach(() => {
+ sinon.spy(store, 'dispatch');
+ });
+
+ afterEach(() => {
+ store.dispatch.restore();
+ });
+
+ it('shows snackbar when loading new list of issues', async () => {
+ sinon.stub(element, 'stateChanged');
+ sinon.stub(element, '_showIssueLoadingSnackbar');
+
+ element._fetchingIssueList = true;
+ element.totalIssues = 1;
+ element.issues = [{localId: 1, projectName: 'chromium'}];
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._showIssueLoadingSnackbar);
+ });
+
+ it('hides snackbar when issues are done loading', async () => {
+ element._fetchingIssueList = true;
+ element.totalIssues = 1;
+ element.issues = [{localId: 1, projectName: 'chromium'}];
+
+ await element.updateComplete;
+
+ sinon.assert.neverCalledWith(store.dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'FETCH_ISSUE_LIST'});
+
+ element._fetchingIssueList = false;
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(store.dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'FETCH_ISSUE_LIST'});
+ });
+
+ it('hides snackbar when <mr-list-page> disconnects', async () => {
+ document.body.removeChild(element);
+
+ sinon.assert.calledWith(store.dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'FETCH_ISSUE_LIST'});
+
+ document.body.appendChild(element);
+ });
+
+ it('shows snackbar on issue loading error', async () => {
+ sinon.stub(element, 'stateChanged');
+ sinon.stub(element, '_showIssueErrorSnackbar');
+
+ element._fetchIssueListError = 'Something went wrong';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element._showIssueErrorSnackbar,
+ 'Something went wrong');
+ });
+ });
+
+ it('shows no issues when no search results', async () => {
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 0;
+ element._queryParams = {q: 'owner:me'};
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.isNull(loading);
+ assert.isNotNull(noIssues);
+ assert.isNull(issueList);
+
+ assert.equal(noIssues.querySelector('strong').textContent.trim(),
+ 'owner:me');
+ });
+
+ it('offers consider closed issues when no open results', async () => {
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 0;
+ element._queryParams = {q: 'owner:me', can: '2'};
+
+ await element.updateComplete;
+
+ const considerClosed = element.shadowRoot.querySelector('.consider-closed');
+
+ assert.isFalse(considerClosed.hidden);
+
+ element._queryParams = {q: 'owner:me', can: '1'};
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ await element.updateComplete;
+
+ assert.isTrue(considerClosed.hidden);
+ });
+
+ it('refreshes when _queryParams.sort changes', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._queryParams = {q: ''};
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element._queryParams = {q: '', colspec: 'Summary+ID'};
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element._queryParams = {q: '', sort: '-Summary'};
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 2);
+
+ element.refresh.restore();
+ });
+
+ it('refreshes when currentQuery changes', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._queryParams = {q: ''};
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element.currentQuery = 'some query term';
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 2);
+
+ element.refresh.restore();
+ });
+
+ it('does not refresh when presentation config not fetched', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._presentationConfigLoaded = false;
+ element.currentQuery = 'some query term';
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 0);
+
+ element.refresh.restore();
+ });
+
+ it('refreshes if presentation config fetch finishes last', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._presentationConfigLoaded = false;
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 0);
+
+ element._presentationConfigLoaded = true;
+ element.currentQuery = 'some query term';
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element.refresh.restore();
+ });
+
+ it('startIndex parses _queryParams for value', () => {
+ // Default value.
+ element._queryParams = {};
+ assert.equal(element.startIndex, 0);
+
+ // Int.
+ element._queryParams = {start: 2};
+ assert.equal(element.startIndex, 2);
+
+ // String.
+ element._queryParams = {start: '5'};
+ assert.equal(element.startIndex, 5);
+
+ // Negative value.
+ element._queryParams = {start: -5};
+ assert.equal(element.startIndex, 0);
+
+ // NaN
+ element._queryParams = {start: 'lol'};
+ assert.equal(element.startIndex, 0);
+ });
+
+ it('maxItems parses _queryParams for value', () => {
+ // Default value.
+ element._queryParams = {};
+ assert.equal(element.maxItems, DEFAULT_ISSUES_PER_PAGE);
+
+ // Int.
+ element._queryParams = {num: 50};
+ assert.equal(element.maxItems, 50);
+
+ // String.
+ element._queryParams = {num: '33'};
+ assert.equal(element.maxItems, 33);
+
+ // NaN
+ element._queryParams = {num: 'lol'};
+ assert.equal(element.maxItems, DEFAULT_ISSUES_PER_PAGE);
+ });
+
+ it('parses groupby parameter correctly', () => {
+ element._queryParams = {groupby: 'Priority+Status'};
+
+ assert.deepEqual(element.groups,
+ ['Priority', 'Status']);
+ });
+
+ it('groupby parsing preserves dashed parameters', () => {
+ element._queryParams = {groupby: 'Priority+Custom-Status'};
+
+ assert.deepEqual(element.groups,
+ ['Priority', 'Custom-Status']);
+ });
+
+ describe('pagination', () => {
+ beforeEach(() => {
+ // Stop Redux from overriding values being tested.
+ sinon.stub(element, 'stateChanged');
+ });
+
+ it('issue count hidden when no issues', async () => {
+ element._queryParams = {num: 10, start: 0};
+ element.totalIssues = 0;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.isTrue(count.hidden);
+ });
+
+ it('issue count renders on first page', async () => {
+ element._queryParams = {num: 10, start: 0};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '1 - 10 of 100');
+ });
+
+ it('issue count renders on middle page', async () => {
+ element._queryParams = {num: 10, start: 50};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '51 - 60 of 100');
+ });
+
+ it('issue count renders on last page', async () => {
+ element._queryParams = {num: 10, start: 95};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '96 - 100 of 100');
+ });
+
+ it('issue count renders on single page', async () => {
+ element._queryParams = {num: 100, start: 0};
+ element.totalIssues = 33;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '1 - 33 of 33');
+ });
+
+ it('total issue count shows backend limit of 100,000', () => {
+ element.totalIssues = SERVER_LIST_ISSUES_LIMIT;
+ assert.equal(element.totalIssuesDisplay, '100,000+');
+ });
+
+ it('next and prev hidden on single page', async () => {
+ element._queryParams = {num: 500, start: 0};
+ element.totalIssues = 10;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNull(next);
+ assert.isNull(prev);
+ });
+
+ it('prev hidden on first page', async () => {
+ element._queryParams = {num: 10, start: 0};
+ element.totalIssues = 30;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNotNull(next);
+ assert.isNull(prev);
+ });
+
+ it('next hidden on last page', async () => {
+ element._queryParams = {num: 10, start: 9};
+ element.totalIssues = 5;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNull(next);
+ assert.isNotNull(prev);
+ });
+
+ it('next and prev shown on middle page', async () => {
+ element._queryParams = {num: 10, start: 50};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNotNull(next);
+ assert.isNotNull(prev);
+ });
+ });
+
+ describe('edit actions', () => {
+ beforeEach(() => {
+ sinon.stub(window, 'alert');
+
+ // Give the test user edit privileges.
+ element._isLoggedIn = true;
+ element._currentUser = {isSiteAdmin: true};
+ });
+
+ afterEach(() => {
+ window.alert.restore();
+ });
+
+ it('edit actions hidden when user is logged out', async () => {
+ element._isLoggedIn = false;
+
+ await element.updateComplete;
+
+ assert.isNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('edit actions hidden when user is not a project member', async () => {
+ element._isLoggedIn = true;
+ element._currentUser = {displayName: 'regular@user.com'};
+
+ await element.updateComplete;
+
+ assert.isNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('edit actions shown when user is a project member', async () => {
+ element.projectName = 'chromium';
+ element._isLoggedIn = true;
+ element._currentUser = {isSiteAdmin: false, userId: '123'};
+ element._usersProjects = new Map([['123', {ownerOf: ['chromium']}]]);
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.shadowRoot.querySelector('mr-button-bar'));
+
+ element.projectName = 'nonmember-project';
+ await element.updateComplete;
+
+ assert.isNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('edit actions shown when user is a site admin', async () => {
+ element._isLoggedIn = true;
+ element._currentUser = {isSiteAdmin: true};
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('bulk edit stops when no issues selected', () => {
+ element.selectedIssues = [];
+ element.projectName = 'test';
+
+ element.bulkEdit();
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to edit.');
+ });
+
+ it('bulk edit redirects to bulk edit page', () => {
+ element.page = sinon.stub();
+ element.selectedIssues = [
+ {localId: 1},
+ {localId: 2},
+ ];
+ element.projectName = 'test';
+
+ element.bulkEdit();
+
+ sinon.assert.calledWith(element.page,
+ '/p/test/issues/bulkedit?ids=1%2C2');
+ });
+
+ it('flag issue as spam stops when no issues selected', () => {
+ element.selectedIssues = [];
+
+ element._flagIssues(true);
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to flag as spam.');
+ });
+
+ it('un-flag issue as spam stops when no issues selected', () => {
+ element.selectedIssues = [];
+
+ element._flagIssues(false);
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to un-flag as spam.');
+ });
+
+ it('flagging issues as spam sends pRPC request', async () => {
+ element.page = sinon.stub();
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+
+ await element._flagIssues(true);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'FlagIssues', {
+ issueRefs: [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ],
+ flag: true,
+ });
+ });
+
+ it('un-flagging issues as spam sends pRPC request', async () => {
+ element.page = sinon.stub();
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+
+ await element._flagIssues(false);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'FlagIssues', {
+ issueRefs: [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ],
+ flag: false,
+ });
+ });
+
+ it('clicking change columns opens dialog', async () => {
+ await element.updateComplete;
+ const dialog = element.shadowRoot.querySelector('mr-change-columns');
+ sinon.stub(dialog, 'open');
+
+ element.openColumnsDialog();
+
+ sinon.assert.calledOnce(dialog.open);
+ });
+
+ it('add to hotlist stops when no issues selected', () => {
+ element.selectedIssues = [];
+ element.projectName = 'test';
+
+ element.addToHotlist();
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to add to hotlists.');
+ });
+
+ it('add to hotlist dialog opens', async () => {
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+ element.projectName = 'test';
+
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector(
+ 'mr-update-issue-hotlists-dialog');
+
+ sinon.stub(dialog, 'open');
+
+ element.addToHotlist();
+
+ sinon.assert.calledOnce(dialog.open);
+ });
+
+ it('hotlist update triggers snackbar', async () => {
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+ element.projectName = 'test';
+ sinon.stub(element, '_showHotlistSaveSnackbar');
+
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector(
+ 'mr-update-issue-hotlists-dialog');
+
+ element.addToHotlist();
+ dialog.dispatchEvent(new Event('saveSuccess'));
+
+ sinon.assert.calledOnce(element._showHotlistSaveSnackbar);
+ });
+ });
+});
diff --git a/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.js b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.js
new file mode 100644
index 0000000..8876402
--- /dev/null
+++ b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.js
@@ -0,0 +1,54 @@
+// 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 {ChopsChoiceButtons} from
+ 'elements/chops/chops-choice-buttons/chops-choice-buttons.js';
+import {urlWithNewParams} from 'shared/helpers.js';
+
+/**
+ * Component for showing the chips to switch between List, Grid, and Chart modes
+ * on the Monorail issue list page.
+ * @extends {ChopsChoiceButtons}
+ */
+export class MrModeSelector extends ChopsChoiceButtons {
+ /** @override */
+ static get properties() {
+ return {
+ ...ChopsChoiceButtons.properties,
+ queryParams: {type: Object},
+ projectName: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.queryParams = {};
+ this.projectName = '';
+
+ this._page = page;
+ };
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('queryParams') ||
+ changedProperties.has('projectName')) {
+ this.options = [
+ {text: 'List', value: 'list', url: this._newListViewPath()},
+ {text: 'Grid', value: 'grid', url: this._newListViewPath('grid')},
+ {text: 'Chart', value: 'chart', url: this._newListViewPath('chart')},
+ ];
+ }
+ super.update(changedProperties);
+ }
+
+ _newListViewPath(mode) {
+ const basePath = `/p/${this.projectName}/issues/list`;
+ const deletedParams = mode ? undefined : ['mode'];
+ return urlWithNewParams(basePath, this.queryParams, {mode}, deletedParams);
+ }
+};
+
+customElements.define('mr-mode-selector', MrModeSelector);
diff --git a/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.test.js b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.test.js
new file mode 100644
index 0000000..07166d6
--- /dev/null
+++ b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.test.js
@@ -0,0 +1,42 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {MrModeSelector} from './mr-mode-selector.js';
+
+let element;
+
+describe('mr-mode-selector', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-mode-selector');
+ document.body.appendChild(element);
+
+ element._page = sinon.stub();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrModeSelector);
+ });
+
+ it('renders links with projectName and queryParams', async () => {
+ element.value = 'list';
+ element.projectName = 'chromium';
+ element.queryParams = {q: 'hello-world'};
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('a');
+
+ assert.include(links[0].href, '/p/chromium/issues/list?q=hello-world');
+ assert.include(links[1].href,
+ '/p/chromium/issues/list?q=hello-world&mode=grid');
+ assert.include(links[2].href,
+ '/p/chromium/issues/list?q=hello-world&mode=chart');
+ });
+});
diff --git a/static_src/elements/mr-app/mr-app.js b/static_src/elements/mr-app/mr-app.js
new file mode 100644
index 0000000..a48d40f
--- /dev/null
+++ b/static_src/elements/mr-app/mr-app.js
@@ -0,0 +1,587 @@
+// 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} from 'lit-element';
+import {repeat} from 'lit-html/directives/repeat';
+import page from 'page';
+import qs from 'qs';
+
+import {getServerStatusCron} from 'shared/cron.js';
+import 'elements/framework/mr-site-banner/mr-site-banner.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as permissions from 'reducers/permissions.js';
+import * as users from 'reducers/users.js';
+import * as userv0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+import {trackPageChange} from 'shared/ga-helpers.js';
+import 'elements/chops/chops-announcement/chops-announcement.js';
+import 'elements/issue-list/mr-list-page/mr-list-page.js';
+import 'elements/issue-entry/mr-issue-entry-page.js';
+import 'elements/framework/mr-header/mr-header.js';
+import 'elements/help/mr-cue/mr-cue.js';
+import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
+import 'elements/chops/chops-snackbar/chops-snackbar.js';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+const QUERY_PARAMS_THAT_RESET_SCROLL = ['q', 'mode', 'id'];
+
+/**
+ * `<mr-app>`
+ *
+ * The container component for all pages under the Monorail SPA.
+ *
+ */
+export class MrApp extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ if (this.page === 'wizard') {
+ return html`<div id="reactMount"></div>`;
+ }
+
+ return html`
+ <style>
+ ${SHARED_STYLES}
+ mr-app {
+ display: block;
+ padding-top: var(--monorail-header-height);
+ margin-top: -1px; /* Prevent a double border from showing up. */
+
+ /* From shared-styles.js. */
+ --mr-edit-field-padding: 0.125em 4px;
+ --mr-edit-field-width: 90%;
+ --mr-input-grid-gap: 6px;
+ font-family: var(--chops-font-family);
+ color: var(--chops-primary-font-color);
+ font-size: var(--chops-main-font-size);
+ }
+ main {
+ border-top: var(--chops-normal-border);
+ }
+ .snackbar-container {
+ position: fixed;
+ bottom: 1em;
+ left: 1em;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ z-index: 1000;
+ }
+ /** Unfix <chops-snackbar> to allow stacking. */
+ chops-snackbar {
+ position: static;
+ margin-top: 0.5em;
+ }
+ </style>
+ <mr-header
+ .userDisplayName=${this.userDisplayName}
+ .loginUrl=${this.loginUrl}
+ .logoutUrl=${this.logoutUrl}
+ ></mr-header>
+ <chops-announcement service="monorail"></chops-announcement>
+ <mr-site-banner></mr-site-banner>
+ <mr-cue
+ cuePrefName=${cueNames.SWITCH_TO_PARENT_ACCOUNT}
+ .loginUrl=${this.loginUrl}
+ centered
+ nondismissible
+ ></mr-cue>
+ <mr-cue
+ cuePrefName=${cueNames.SEARCH_FOR_NUMBERS}
+ centered
+ ></mr-cue>
+ <main>${this._renderPage()}</main>
+ <div class="snackbar-container" aria-live="polite">
+ ${repeat(this._snackbars, (snackbar) => html`
+ <chops-snackbar
+ @close=${this._closeSnackbar.bind(this, snackbar.id)}
+ >${snackbar.text}</chops-snackbar>
+ `)}
+ </div>
+ `;
+ }
+
+ /**
+ * @param {string} id The name of the snackbar to close.
+ */
+ _closeSnackbar(id) {
+ store.dispatch(ui.hideSnackbar(id));
+ }
+
+ /**
+ * Helper for determiing which page component to render.
+ * @return {TemplateResult}
+ */
+ _renderPage() {
+ switch (this.page) {
+ case 'detail':
+ return html`
+ <mr-issue-page
+ .userDisplayName=${this.userDisplayName}
+ .loginUrl=${this.loginUrl}
+ ></mr-issue-page>
+ `;
+ case 'entry':
+ return html`
+ <mr-issue-entry-page
+ .userDisplayName=${this.userDisplayName}
+ .loginUrl=${this.loginUrl}
+ ></mr-issue-entry-page>
+ `;
+ case 'grid':
+ return html`
+ <mr-grid-page
+ .userDisplayName=${this.userDisplayName}
+ ></mr-grid-page>
+ `;
+ case 'list':
+ return html`
+ <mr-list-page
+ .userDisplayName=${this.userDisplayName}
+ ></mr-list-page>
+ `;
+ case 'chart':
+ return html`<mr-chart-page></mr-chart-page>`;
+ case 'projects':
+ return html`<mr-projects-page></mr-projects-page>`;
+ case 'hotlist-issues':
+ return html`<mr-hotlist-issues-page></mr-hotlist-issues-page>`;
+ case 'hotlist-people':
+ return html`<mr-hotlist-people-page></mr-hotlist-people-page>`;
+ case 'hotlist-settings':
+ return html`<mr-hotlist-settings-page></mr-hotlist-settings-page>`;
+ default:
+ return;
+ }
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * Backend-generated URL for the page the user is directed to for login.
+ */
+ loginUrl: {type: String},
+ /**
+ * Backend-generated URL for the page the user is directed to for logout.
+ */
+ logoutUrl: {type: String},
+ /**
+ * The display name of the currently logged in user.
+ */
+ userDisplayName: {type: String},
+ /**
+ * The search parameters in the user's current URL.
+ */
+ queryParams: {type: Object},
+ /**
+ * A list of forms to check for "dirty" values when the user navigates
+ * across pages.
+ */
+ dirtyForms: {type: Array},
+ /**
+ * App Engine ID for the current version being viewed.
+ */
+ versionBase: {type: String},
+ /**
+ * A String identifier for the page that the user is viewing.
+ */
+ page: {type: String},
+ /**
+ * A String for the title of the page that the user will see in their
+ * browser tab. ie: equivalent to the <title> tag.
+ */
+ pageTitle: {type: String},
+ /**
+ * Array of snackbar objects to render.
+ */
+ _snackbars: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.queryParams = {};
+ this.dirtyForms = [];
+ this.userDisplayName = '';
+
+ /**
+ * @type {PageJS.Context}
+ * The context of the page. This should not be a LitElement property
+ * because we don't want to re-render when updating this.
+ */
+ this._lastContext = undefined;
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.dirtyForms = ui.dirtyForms(state);
+ this.queryParams = sitewide.queryParams(state);
+ this.pageTitle = sitewide.pageTitle(state);
+ this._snackbars = ui.snackbars(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('userDisplayName') && this.userDisplayName) {
+ // TODO(https://crbug.com/monorail/7238): Migrate userv0 calls to v3 API.
+ store.dispatch(userv0.fetch(this.userDisplayName));
+
+ // Typically we would prefer 'users/<userId>' instead.
+ store.dispatch(users.fetch(`users/${this.userDisplayName}`));
+ }
+
+ if (changedProperties.has('pageTitle')) {
+ // To ensure that changes to the page title are easy to reason about,
+ // we want to sync the current pageTitle in the Redux state to
+ // document.title in only one place in the code.
+ document.title = this.pageTitle;
+ }
+ if (changedProperties.has('page')) {
+ trackPageChange(this.page, this.userDisplayName);
+ }
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ // TODO(zhangtiff): Figure out some way to save Redux state between
+ // page loads.
+
+ // page doesn't handle users reloading the page or closing a tab.
+ window.onbeforeunload = this._confirmDiscardMessage.bind(this);
+
+ // Start a cron task to periodically request the status from the server.
+ getServerStatusCron.start();
+
+ const postRouteHandler = this._postRouteHandler.bind(this);
+
+ // Populate the project route parameter before _preRouteHandler runs.
+ page('/p/:project/*', (_ctx, next) => next());
+ page('*', this._preRouteHandler.bind(this));
+
+ page('/hotlists/:hotlist', (ctx) => {
+ page.redirect(`/hotlists/${ctx.params.hotlist}/issues`);
+ });
+ page('/hotlists/:hotlist/*', this._selectHotlist);
+ page('/hotlists/:hotlist/issues',
+ this._loadHotlistIssuesPage.bind(this), postRouteHandler);
+ page('/hotlists/:hotlist/people',
+ this._loadHotlistPeoplePage.bind(this), postRouteHandler);
+ page('/hotlists/:hotlist/settings',
+ this._loadHotlistSettingsPage.bind(this), postRouteHandler);
+
+ // Handle Monorail's landing page.
+ page('/p', '/');
+ page('/projects', '/');
+ page('/hosting', '/');
+ page('/', this._loadProjectsPage.bind(this), postRouteHandler);
+
+ page('/p/:project/issues/list', this._loadListPage.bind(this),
+ postRouteHandler);
+ page('/p/:project/issues/detail', this._loadIssuePage.bind(this),
+ postRouteHandler);
+ page('/p/:project/issues/entry_new', this._loadEntryPage.bind(this),
+ postRouteHandler);
+ page('/p/:project/issues/wizard', this._loadWizardPage.bind(this),
+ postRouteHandler);
+
+ // Redirects from old hotlist pages to SPA hotlist pages.
+ const hotlistRedirect = (pageName) => async (ctx) => {
+ const name =
+ await hotlists.getHotlistName(ctx.params.user, ctx.params.hotlist);
+ page.redirect(`/${name}/${pageName}`);
+ };
+ page('/users/:user/hotlists/:hotlist', hotlistRedirect('issues'));
+ page('/users/:user/hotlists/:hotlist/people', hotlistRedirect('people'));
+ page('/users/:user/hotlists/:hotlist/details', hotlistRedirect('settings'));
+
+ page();
+ }
+
+ /**
+ * Handler that runs on every single route change, before the new page has
+ * loaded. This function should not use store.dispatch() or assign properties
+ * on this because running these actions causes extra re-renders to happen.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ _preRouteHandler(ctx, next) {
+ // We're not really navigating anywhere, so don't do anything.
+ if (this._lastContext && this._lastContext.path &&
+ ctx.path === this._lastContext.path) {
+ Object.assign(ctx, this._lastContext);
+ // Set ctx.handled to false, so we don't push the state to browser's
+ // history.
+ ctx.handled = false;
+ return;
+ }
+
+ // Check if there were forms with unsaved data before loading the next
+ // page.
+ const discardMessage = this._confirmDiscardMessage();
+ if (discardMessage && !confirm(discardMessage)) {
+ Object.assign(ctx, this._lastContext);
+ // Set ctx.handled to false, so we don't push the state to browser's
+ // history.
+ ctx.handled = false;
+ // We don't call next to avoid loading whatever page was supposed to
+ // load next.
+ return;
+ }
+
+ // Run query string parsing on all routes. Query params must be parsed
+ // before routes are loaded because some routes use them to conditionally
+ // load bundles.
+ // Based on: https://visionmedia.github.io/page.js/#plugins
+ const params = qs.parse(ctx.querystring);
+
+ // Make sure queryParams are not case sensitive.
+ const lowerCaseParams = {};
+ Object.keys(params).forEach((key) => {
+ lowerCaseParams[key.toLowerCase()] = params[key];
+ });
+ ctx.queryParams = lowerCaseParams;
+
+ this._selectProject(ctx.params.project);
+
+ next();
+ }
+
+ /**
+ * Handler that runs on every single route change, after the new page has
+ * loaded.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ _postRouteHandler(ctx, next) {
+ // Scroll to the requested element if a hash is present.
+ if (ctx.hash) {
+ store.dispatch(ui.setFocusId(ctx.hash));
+ }
+
+ // Sync queryParams to Redux after the route has loaded, rather than before,
+ // to avoid having extra queryParams update on the previously loaded
+ // component.
+ store.dispatch(sitewide.setQueryParams(ctx.queryParams));
+
+ // Increment the count of navigations in the Redux store.
+ store.dispatch(ui.incrementNavigationCount());
+
+ // Clear dirty forms when entering a new page.
+ store.dispatch(ui.clearDirtyForms());
+
+
+ if (!this._lastContext || this._lastContext.pathname !== ctx.pathname ||
+ this._hasReleventParamChanges(ctx.queryParams,
+ this._lastContext.queryParams)) {
+ // Reset the scroll position after a new page has rendered.
+ window.scrollTo(0, 0);
+ }
+
+ // Save the context of this page to be compared to later.
+ this._lastContext = ctx;
+ }
+
+ /**
+ * Finds if a route change changed query params in a way that should cause
+ * scrolling to reset.
+ * @param {Object} currentParams
+ * @param {Object} oldParams
+ * @param {Array<string>=} paramsToCompare Which params to check.
+ * @return {boolean} Whether any of the relevant query params changed.
+ */
+ _hasReleventParamChanges(currentParams, oldParams,
+ paramsToCompare = QUERY_PARAMS_THAT_RESET_SCROLL) {
+ return paramsToCompare.some((paramName) => {
+ return currentParams[paramName] !== oldParams[paramName];
+ });
+ }
+
+ /**
+ * Helper to manage syncing project route state to Redux.
+ * @param {string=} project displayName for a referenced project.
+ * Defaults to null for consistency with Redux.
+ */
+ _selectProject(project = null) {
+ if (projectV0.viewedProjectName(store.getState()) !== project) {
+ // Note: We want to update the project even if the new project
+ // is null.
+ store.dispatch(projectV0.select(project));
+ if (project) {
+ store.dispatch(projectV0.fetch(project));
+ }
+ }
+ }
+
+ /**
+ * Loads and triggers rendering for the list of all projects.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ async _loadProjectsPage(ctx, next) {
+ await import(/* webpackChunkName: "mr-projects-page" */
+ '../projects/mr-projects-page/mr-projects-page.js');
+ this.page = 'projects';
+ next();
+ }
+
+ /**
+ * Loads and triggers render for the issue detail page.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ async _loadIssuePage(ctx, next) {
+ performance.clearMarks('start load issue detail page');
+ performance.mark('start load issue detail page');
+
+ await import(/* webpackChunkName: "mr-issue-page" */
+ '../issue-detail/mr-issue-page/mr-issue-page.js');
+
+ const issueRef = {
+ localId: Number.parseInt(ctx.queryParams.id),
+ projectName: ctx.params.project,
+ };
+ store.dispatch(issueV0.viewIssue(issueRef));
+ store.dispatch(issueV0.fetchIssuePageData(issueRef));
+ this.page = 'detail';
+ next();
+ }
+
+ /**
+ * Loads and triggers render for the issue list page, including the list,
+ * grid, and chart modes.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ async _loadListPage(ctx, next) {
+ performance.clearMarks('start load issue list page');
+ performance.mark('start load issue list page');
+ switch (ctx.queryParams && ctx.queryParams.mode &&
+ ctx.queryParams.mode.toLowerCase()) {
+ case 'grid':
+ await import(/* webpackChunkName: "mr-grid-page" */
+ '../issue-list/mr-grid-page/mr-grid-page.js');
+ this.page = 'grid';
+ break;
+ case 'chart':
+ await import(/* webpackChunkName: "mr-chart-page" */
+ '../issue-list/mr-chart-page/mr-chart-page.js');
+ this.page = 'chart';
+ break;
+ default:
+ this.page = 'list';
+ break;
+ }
+ next();
+ }
+
+ /**
+ * Load the issue entry page
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ _loadEntryPage(ctx, next) {
+ this.page = 'entry';
+ next();
+ }
+
+ /**
+ * Load the issue wizard
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ async _loadWizardPage(ctx, next) {
+ const {renderWizard} = await import(
+ /* webpackChunkName: "IssueWizard" */ '../../react/IssueWizard.tsx');
+
+ this.page = 'wizard';
+ next();
+
+ await this.updateComplete;
+
+ const mount = document.getElementById('reactMount');
+
+ renderWizard(mount);
+ }
+
+ /**
+ * Gets the currently viewed HotlistRef from the URL, selects
+ * it in the Redux store, and fetches the Hotlist data.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ _selectHotlist(ctx, next) {
+ const name = 'hotlists/' + ctx.params.hotlist;
+ store.dispatch(hotlists.select(name));
+ store.dispatch(hotlists.fetch(name));
+ store.dispatch(hotlists.fetchItems(name));
+ store.dispatch(permissions.batchGet([name]));
+ next();
+ }
+
+ /**
+ * Loads mr-hotlist-issues-page.js and makes it the currently viewed page.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ async _loadHotlistIssuesPage(ctx, next) {
+ await import(/* webpackChunkName: "mr-hotlist-issues-page" */
+ `../hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js`);
+ this.page = 'hotlist-issues';
+ next();
+ }
+
+ /**
+ * Loads mr-hotlist-people-page.js and makes it the currently viewed page.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ async _loadHotlistPeoplePage(ctx, next) {
+ await import(/* webpackChunkName: "mr-hotlist-people-page" */
+ `../hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js`);
+ this.page = 'hotlist-people';
+ next();
+ }
+
+ /**
+ * Loads mr-hotlist-settings-page.js and makes it the currently viewed page.
+ * @param {PageJS.Context} ctx A page.js Context containing routing state.
+ * @param {function} next Passes execution on to the next registered callback.
+ */
+ async _loadHotlistSettingsPage(ctx, next) {
+ await import(/* webpackChunkName: "mr-hotlist-settings-page" */
+ `../hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js`);
+ this.page = 'hotlist-settings';
+ next();
+ }
+
+ /**
+ * Constructs a message to warn users about dirty forms when they navigate
+ * away from a page, to prevent them from loasing data.
+ * @return {string} Message shown to users to warn about in flight form
+ * changes.
+ */
+ _confirmDiscardMessage() {
+ if (!this.dirtyForms.length) return null;
+ const dirtyFormsMessage =
+ 'Discard your changes in the following forms?\n' +
+ arrayToEnglish(this.dirtyForms);
+ return dirtyFormsMessage;
+ }
+}
+
+customElements.define('mr-app', MrApp);
diff --git a/static_src/elements/mr-app/mr-app.test.js b/static_src/elements/mr-app/mr-app.test.js
new file mode 100644
index 0000000..47b953b
--- /dev/null
+++ b/static_src/elements/mr-app/mr-app.test.js
@@ -0,0 +1,300 @@
+// 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 {MrApp} from './mr-app.js';
+import {store, resetState} from 'reducers/base.js';
+import {select} from 'reducers/projectV0.js';
+
+let element;
+let next;
+
+window.CS_env = {
+ token: 'foo-token',
+};
+
+describe('mr-app', () => {
+ beforeEach(() => {
+ global.ga = sinon.spy();
+ store.dispatch(resetState());
+ element = document.createElement('mr-app');
+ document.body.appendChild(element);
+ element.formsToCheck = [];
+
+ next = sinon.stub();
+ });
+
+ afterEach(() => {
+ global.ga.resetHistory();
+ document.body.removeChild(element);
+ next.reset();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrApp);
+ });
+
+ describe('snackbar handling', () => {
+ beforeEach(() => {
+ sinon.spy(store, 'dispatch');
+ });
+
+ afterEach(() => {
+ store.dispatch.restore();
+ });
+
+ it('renders no snackbars', async () => {
+ element._snackbars = [];
+
+ await element.updateComplete;
+
+ const snackbars = element.querySelectorAll('chops-snackbar');
+
+ assert.equal(snackbars.length, 0);
+ });
+
+ it('renders multiple snackbars', async () => {
+ element._snackbars = [
+ {text: 'Snackbar one', id: 'one'},
+ {text: 'Snackbar two', id: 'two'},
+ {text: 'Snackbar three', id: 'thre'},
+ ];
+
+ await element.updateComplete;
+
+ const snackbars = element.querySelectorAll('chops-snackbar');
+
+ assert.equal(snackbars.length, 3);
+
+ assert.include(snackbars[0].textContent, 'Snackbar one');
+ assert.include(snackbars[1].textContent, 'Snackbar two');
+ assert.include(snackbars[2].textContent, 'Snackbar three');
+ });
+
+ it('closing snackbar hides snackbar', async () => {
+ element._snackbars = [
+ {text: 'Snackbar', id: 'one'},
+ ];
+
+ await element.updateComplete;
+
+ const snackbar = element.querySelector('chops-snackbar');
+
+ snackbar.close();
+
+ sinon.assert.calledWith(store.dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'one'});
+ });
+ });
+
+ it('_preRouteHandler calls next()', () => {
+ const ctx = {params: {}};
+
+ element._preRouteHandler(ctx, next);
+
+ sinon.assert.calledOnce(next);
+ });
+
+ it('_preRouteHandler does not call next() on same page nav', () => {
+ element._lastContext = {path: '123'};
+ const ctx = {params: {}, path: '123'};
+
+ element._preRouteHandler(ctx, next);
+
+ assert.isFalse(ctx.handled);
+ sinon.assert.notCalled(next);
+ });
+
+ it('_preRouteHandler parses queryParams', () => {
+ const ctx = {params: {}, querystring: 'q=owner:me&colspec=Summary'};
+ element._preRouteHandler(ctx, next);
+
+ assert.deepEqual(ctx.queryParams, {q: 'owner:me', colspec: 'Summary'});
+ });
+
+ it('_preRouteHandler ignores case for queryParams keys', () => {
+ const ctx = {params: {},
+ querystring: 'Q=owner:me&ColSpeC=Summary&x=owner'};
+ element._preRouteHandler(ctx, next);
+
+ assert.deepEqual(ctx.queryParams, {q: 'owner:me', colspec: 'Summary',
+ x: 'owner'});
+ });
+
+ it('_preRouteHandler ignores case for queryParams keys', () => {
+ const ctx = {params: {},
+ querystring: 'Q=owner:me&ColSpeC=Summary&x=owner'};
+ element._preRouteHandler(ctx, next);
+
+ assert.deepEqual(ctx.queryParams, {q: 'owner:me', colspec: 'Summary',
+ x: 'owner'});
+ });
+
+ it('_postRouteHandler saves ctx.queryParams to Redux', () => {
+ const ctx = {queryParams: {q: '1234'}};
+ element._postRouteHandler(ctx, next);
+
+ assert.deepEqual(element.queryParams, {q: '1234'});
+ });
+
+ it('_postRouteHandler saves ctx to this._lastContext', () => {
+ const ctx = {path: '1234'};
+ element._postRouteHandler(ctx, next);
+
+ assert.deepEqual(element._lastContext, {path: '1234'});
+ });
+
+ describe('scroll to the top on page changes', () => {
+ beforeEach(() => {
+ sinon.stub(window, 'scrollTo');
+ });
+
+ afterEach(() => {
+ window.scrollTo.restore();
+ });
+
+ it('scrolls page to top on initial load', () => {
+ element._lastContext = null;
+ const ctx = {params: {}, path: '1234'};
+ element._postRouteHandler(ctx, next);
+
+ sinon.assert.calledWith(window.scrollTo, 0, 0);
+ });
+
+ it('scrolls page to top on parh change', () => {
+ element._lastContext = {params: {}, pathname: '/list',
+ path: '/list?q=123', querystring: '?q=123', queryParams: {q: '123'}};
+ const ctx = {params: {}, pathname: '/other',
+ path: '/other?q=123', querystring: '?q=123', queryParams: {q: '123'}};
+
+ element._postRouteHandler(ctx, next);
+
+ sinon.assert.calledWith(window.scrollTo, 0, 0);
+ });
+
+ it('does not scroll to top when on the same path', () => {
+ element._lastContext = {pathname: '/list', path: '/list?q=123',
+ querystring: '?a=123', queryParams: {a: '123'}};
+ const ctx = {pathname: '/list', path: '/list?q=456',
+ querystring: '?a=456', queryParams: {a: '456'}};
+
+ element._postRouteHandler(ctx, next);
+
+ sinon.assert.notCalled(window.scrollTo);
+ });
+
+ it('scrolls to the top on same path when q param changes', () => {
+ element._lastContext = {pathname: '/list', path: '/list?q=123',
+ querystring: '?q=123', queryParams: {q: '123'}};
+ const ctx = {pathname: '/list', path: '/list?q=456',
+ querystring: '?q=456', queryParams: {q: '456'}};
+
+ element._postRouteHandler(ctx, next);
+
+ sinon.assert.calledWith(window.scrollTo, 0, 0);
+ });
+ });
+
+
+ it('_postRouteHandler does not call next', () => {
+ const ctx = {path: '1234'};
+ element._postRouteHandler(ctx, next);
+
+ sinon.assert.notCalled(next);
+ });
+
+ it('_loadIssuePage loads issue page', async () => {
+ await element._loadIssuePage({
+ queryParams: {id: '234'},
+ params: {project: 'chromium'},
+ }, next);
+ await element.updateComplete;
+
+ // Check that only one page element is rendering at a time.
+ const main = element.querySelector('main');
+ assert.equal(main.children.length, 1);
+
+ const issuePage = element.querySelector('mr-issue-page');
+ assert.isDefined(issuePage, 'issue page is defined');
+ assert.equal(issuePage.issueRef.projectName, 'chromium');
+ assert.equal(issuePage.issueRef.localId, 234);
+ });
+
+ it('_loadListPage loads list page', async () => {
+ await element._loadListPage({
+ params: {project: 'chromium'},
+ }, next);
+ await element.updateComplete;
+
+ // Check that only one page element is rendering at a time.
+ const main = element.querySelector('main');
+ assert.equal(main.children.length, 1);
+
+ const listPage = element.querySelector('mr-list-page');
+ assert.isDefined(listPage, 'list page is defined');
+ });
+
+ it('_loadListPage loads grid page', async () => {
+ element.queryParams = {mode: 'grid'};
+ await element._loadListPage({
+ params: {project: 'chromium'},
+ }, next);
+ await element.updateComplete;
+
+ // Check that only one page element is rendering at a time.
+ const main = element.querySelector('main');
+ assert.equal(main.children.length, 1);
+
+ const gridPage = element.querySelector('mr-grid-page');
+ assert.isDefined(gridPage, 'grid page is defined');
+ });
+
+ describe('_selectProject', () => {
+ beforeEach(() => {
+ sinon.spy(store, 'dispatch');
+ });
+
+ afterEach(() => {
+ store.dispatch.restore();
+ });
+
+ it('selects and fetches project', () => {
+ const projectName = 'chromium';
+ assert.notEqual(store.getState().projectV0.name, projectName);
+
+ element._selectProject(projectName);
+
+ sinon.assert.calledTwice(store.dispatch);
+ });
+
+ it('skips selecting and fetching when project isn\'t changing', () => {
+ const projectName = 'chromium';
+
+ store.dispatch.restore();
+ store.dispatch(select(projectName));
+ sinon.spy(store, 'dispatch');
+
+ assert.equal(store.getState().projectV0.name, projectName);
+
+ element._selectProject(projectName);
+
+ sinon.assert.notCalled(store.dispatch);
+ });
+
+ it('selects without fetching when transitioning to null', () => {
+ const projectName = 'chromium';
+
+ store.dispatch.restore();
+ store.dispatch(select(projectName));
+ sinon.spy(store, 'dispatch');
+
+ assert.equal(store.getState().projectV0.name, projectName);
+
+ element._selectProject(null);
+
+ sinon.assert.calledOnce(store.dispatch);
+ });
+ });
+});
diff --git a/static_src/elements/projects/mr-projects-page/helpers.js b/static_src/elements/projects/mr-projects-page/helpers.js
new file mode 100644
index 0000000..5c12ae8
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/helpers.js
@@ -0,0 +1,30 @@
+// Copyright 2020 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 {projectMemberToProjectName} from 'shared/converters.js';
+
+// TODO(crbug.com/monorail/7910): Dedupe this with the similar "projectRoles"
+// constant in <mr-header>.
+const projectRoles = Object.freeze({
+ PROJECT_ROLE_UNSPECIFIED: '',
+ OWNER: 'Owner',
+ COMMITTER: 'Committer',
+ CONTRIBUTOR: 'Contributor',
+});
+
+/**
+ * Creates a mapping of project names to the user's role in that project.
+ * @param {Array<ProjectMember>} projectMembers Project memebrships
+ * for a given user.
+ * @return {Object<ProjectName, string>} Mapping of a user's roles,
+ * by project name.
+ */
+export function computeRoleByProjectName(projectMembers) {
+ const mapping = {};
+ if (!projectMembers) return mapping;
+ projectMembers.forEach(({name, role}) => {
+ mapping[projectMemberToProjectName(name)] = projectRoles[role];
+ });
+ return mapping;
+}
diff --git a/static_src/elements/projects/mr-projects-page/helpers.test.js b/static_src/elements/projects/mr-projects-page/helpers.test.js
new file mode 100644
index 0000000..9e3c5a2
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/helpers.test.js
@@ -0,0 +1,24 @@
+// Copyright 2020 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 {computeRoleByProjectName} from './helpers.js';
+
+describe('computeRoleByProjectName', () => {
+ it('handles empty project memberships', () => {
+ assert.deepEqual(computeRoleByProjectName(undefined), {});
+ assert.deepEqual(computeRoleByProjectName([]), {});
+ });
+
+ it('creates mapping', () => {
+ const projectMembers = [
+ {role: 'OWNER', name: 'projects/project-name/members/1234'},
+ {role: 'COMMITTER', name: 'projects/test/members/1234'},
+ ];
+ assert.deepEqual(computeRoleByProjectName(projectMembers), {
+ 'projects/project-name': 'Owner',
+ 'projects/test': 'Committer',
+ });
+ });
+});
diff --git a/static_src/elements/projects/mr-projects-page/mr-projects-page.js b/static_src/elements/projects/mr-projects-page/mr-projects-page.js
new file mode 100644
index 0000000..1124ef0
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/mr-projects-page.js
@@ -0,0 +1,297 @@
+// 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 {store, connectStore} from 'reducers/base.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'elements/framework/mr-star/mr-project-star.js';
+import 'shared/typedef.js';
+import 'elements/chops/chops-chip/chops-chip.js';
+
+import * as projects from 'reducers/projects.js';
+import {users} from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {computeRoleByProjectName} from './helpers.js';
+
+
+/**
+ * `<mr-projects-page>`
+ *
+ * Displays list of all projects.
+ *
+ */
+export class MrProjectsPage extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ box-sizing: border-box;
+ display: block;
+ padding: 1em 8px;
+ padding-left: 40px; /** 32px + 8px */
+ margin: auto;
+ max-width: 1280px;
+ width: 100%;
+ }
+ :host::after {
+ content: "";
+ background-image: url('/static/images/chromium.svg');
+ background-repeat: no-repeat;
+ background-position: right -100px bottom -150px;
+ background-size: 700px;
+ opacity: 0.09;
+ width: 100%;
+ height: 100%;
+ bottom: 0;
+ right: 0;
+ position: fixed;
+ z-index: -1;
+ }
+ h2 {
+ font-size: 20px;
+ letter-spacing: 0.1px;
+ font-weight: 500;
+ margin-top: 1em;
+ }
+ .project-header {
+ display: flex;
+ align-items: flex-start;
+ flex-direction: row;
+ justify-content: space-between;
+ font-size: 16px;
+ line-height: 24px;
+ margin: 0;
+ margin-bottom: 16px;
+ padding-top: 0.1em;
+ padding-bottom: 16px;
+ letter-spacing: 0.1px;
+ font-weight: 500;
+ width: 100%;
+ border-bottom: var(--chops-normal-border);
+ border-color: var(--chops-gray-400);
+ }
+ .project-title {
+ display: flex;
+ flex-direction: column;
+ }
+ h3 {
+ margin: 0;
+ padding: 0;
+ font-weight: inherit;
+ font-size: inherit;
+ transition: color var(--chops-transition-time) ease-in-out;
+ }
+ h3:hover {
+ color: var(--chops-link-color);
+ }
+ .subtitle {
+ color: var(--chops-gray-700);
+ font-size: var(--chops-main-font-size);
+ line-height: 100%;
+ font-weight: normal;
+ }
+ .project-container {
+ display: flex;
+ align-items: stretch;
+ flex-wrap: wrap;
+ width: 100%;
+ padding: 0.5em 0;
+ margin-bottom: 3em;
+ }
+ .project {
+ background: var(--chops-white);
+ width: 220px;
+ margin-right: 32px;
+ margin-bottom: 32px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ border-radius: 4px;
+ border: var(--chops-normal-border);
+ padding: 16px;
+ color: var(--chops-primary-font-color);
+ font-weight: normal;
+ line-height: 16px;
+ transition: all var(--chops-transition-time) ease-in-out;
+ }
+ .project:hover {
+ text-decoration: none;
+ cursor: pointer;
+ box-shadow: 0 2px 6px hsla(0,0%,0%,0.12),
+ 0 1px 3px hsla(0,0%,0%,0.24);
+ }
+ .project > p {
+ margin: 0;
+ margin-bottom: 32px;
+ flex-grow: 1;
+ }
+ .view-project-link {
+ text-transform: uppercase;
+ margin: 0;
+ font-weight: 600;
+ flex-grow: 0;
+ }
+ .view-project-link:hover {
+ text-decoration: underline;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ const myProjects = this.myProjects;
+ const otherProjects = this.otherProjects;
+ const noProjects = !myProjects.length && !otherProjects.length;
+
+ if (this._isFetchingProjects && noProjects) {
+ return html`Loading...`;
+ }
+
+ if (noProjects) {
+ return html`No projects found.`;
+ }
+
+ if (!myProjects.length) {
+ // Skip sorting projects into different sections if the user
+ // has no projects.
+ return html`
+ <h2>All projects</h2>
+ <div class="project-container all-projects">
+ ${otherProjects.map((project) => this._renderProject(project))}
+ </div>
+ `;
+ }
+
+ const myProjectsTemplate = myProjects.map((project) => this._renderProject(
+ project, this._roleByProjectName[project.name]));
+
+ return html`
+ <h2>My projects</h2>
+ <div class="project-container my-projects">
+ ${myProjectsTemplate}
+ </div>
+
+ <h2>Other projects</h2>
+ <div class="project-container other-projects">
+ ${otherProjects.map((project) => this._renderProject(project))}
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ _projects: {type: Array},
+ _isFetchingProjects: {type: Boolean},
+ _currentUser: {type: String},
+ _roleByProjectName: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ /**
+ * @type {Array<Project>}
+ */
+ this._projects = [];
+ /**
+ * @type {boolean}
+ */
+ this._isFetchingProjects = false;
+ /**
+ * @type {string}
+ */
+ this._currentUser = undefined;
+ /**
+ * @type {Object<ProjectName, string>}
+ */
+ this._roleByProjectName = {};
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ store.dispatch(projects.list());
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('_currentUser') && this._currentUser) {
+ const userName = this._currentUser;
+ store.dispatch(users.gatherProjectMemberships(userName));
+ store.dispatch(stars.listProjects(userName));
+ }
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._projects = projects.all(state);
+ this._isFetchingProjects = projects.requests(state).list.requesting;
+ this._currentUser = users.currentUserName(state);
+ const allProjectMemberships = users.projectMemberships(state);
+ this._roleByProjectName = computeRoleByProjectName(
+ allProjectMemberships[this._currentUser]);
+ }
+
+ /**
+ * @param {Project} project
+ * @param {string=} role
+ * @return {TemplateResult}
+ */
+ _renderProject(project, role) {
+ return html`
+ <a href="/p/${project.displayName}/issues/list" class="project">
+ <div class="project-header">
+ <span class="project-title">
+ <h3>${project.displayName}</h3>
+ <span class="subtitle" ?hidden=${!role} title="My role: ${role}">
+ Role: ${role}
+ </span>
+ </span>
+
+ <mr-project-star .name=${project.name}></mr-project-star>
+ </div>
+ <p>
+ ${project.summary}
+ </p>
+ <button class="view-project-link linkify">
+ View project
+ </button>
+ </a>
+ `;
+ }
+
+ /**
+ * Projects the currently logged in user is a member of.
+ * @return {Array<Project>}
+ */
+ get myProjects() {
+ return this._projects.filter(
+ ({name}) => this._userIsMemberOfProject(name));
+ }
+
+ /**
+ * Projects the currently logged in user is not a member of.
+ * @return {Array<Project>}
+ */
+ get otherProjects() {
+ return this._projects.filter(
+ ({name}) => !this._userIsMemberOfProject(name));
+ }
+
+ /**
+ * Helper to check if a user is a member of a project.
+ * @param {ProjectName} project Resource name of a project.
+ * @return {boolean} Whether the user a member of the given project.
+ */
+ _userIsMemberOfProject(project) {
+ return project in this._roleByProjectName;
+ }
+}
+customElements.define('mr-projects-page', MrProjectsPage);
diff --git a/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js b/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js
new file mode 100644
index 0000000..1a9a1e4
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js
@@ -0,0 +1,248 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+import {stateUpdated} from 'reducers/base.js';
+import {users} from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {MrProjectsPage} from './mr-projects-page.js';
+
+let element;
+
+describe('mr-projects-page', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-projects-page');
+ document.body.appendChild(element);
+
+ sinon.stub(element, 'stateChanged');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrProjectsPage);
+ });
+
+ it('renders loading', async () => {
+ element._isFetchingProjects = true;
+
+ await element.updateComplete;
+
+ assert.equal(element.shadowRoot.textContent.trim(), 'Loading...');
+ });
+
+ it('renders projects when refetching projects', async () => {
+ element._isFetchingProjects = true;
+ element._projects = [
+ {name: 'projects/chromium', displayName: 'chromium',
+ summary: 'Best project ever'},
+ ];
+
+ await element.updateComplete;
+
+ const headers = element.shadowRoot.querySelectorAll('h2');
+
+ assert.equal(headers.length, 1);
+ assert.equal(headers[0].textContent.trim(), 'All projects');
+
+ const projects = element.shadowRoot.querySelectorAll(
+ '.all-projects > .project');
+ assert.equal(projects.length, 1);
+
+ assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+ assert.include(projects[0].textContent, 'Best project ever');
+ });
+
+ it('renders all projects when no user projects', async () => {
+ element._isFetchingProjects = false;
+ element._projects = [
+ {name: 'projects/chromium', displayName: 'chromium',
+ summary: 'Best project ever'},
+ {name: 'projects/infra', displayName: 'infra',
+ summary: 'Make it work'},
+ ];
+
+ await element.updateComplete;
+
+ const headers = element.shadowRoot.querySelectorAll('h2');
+
+ assert.equal(headers.length, 1);
+ assert.equal(headers[0].textContent.trim(), 'All projects');
+
+ const projects = element.shadowRoot.querySelectorAll(
+ '.all-projects > .project');
+ assert.equal(projects.length, 2);
+
+ assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+ assert.include(projects[0].textContent, 'Best project ever');
+
+ assert.include(projects[1].querySelector('h3').textContent, 'infra');
+ assert.include(projects[1].textContent, 'Make it work');
+ });
+
+ it('renders no projects found', async () => {
+ element._isFetchingProjects = false;
+ sinon.stub(element, 'myProjects').get(() => []);
+ sinon.stub(element, 'otherProjects').get(() => []);
+
+ await element.updateComplete;
+
+ assert.equal(element.shadowRoot.textContent.trim(), 'No projects found.');
+ });
+
+ describe('project grouping', () => {
+ beforeEach(() => {
+ element._projects = [
+ {name: 'projects/chromium', displayName: 'chromium',
+ summary: 'Best project ever'},
+ {name: 'projects/infra', displayName: 'infra',
+ summary: 'Make it work'},
+ {name: 'projects/test', displayName: 'test',
+ summary: 'Hmm'},
+ {name: 'projects/a-project', displayName: 'a-project',
+ summary: 'I am Monkeyrail'},
+ ];
+ element._roleByProjectName = {
+ 'projects/chromium': 'Owner',
+ 'projects/infra': 'Committer',
+ };
+ element._isFetchingProjects = false;
+ });
+
+ it('myProjects filters out non-member projects', () => {
+ assert.deepEqual(element.myProjects, [
+ {name: 'projects/chromium', displayName: 'chromium',
+ summary: 'Best project ever'},
+ {name: 'projects/infra', displayName: 'infra',
+ summary: 'Make it work'},
+ ]);
+ });
+
+ it('otherProjects filters out member projects', () => {
+ assert.deepEqual(element.otherProjects, [
+ {name: 'projects/test', displayName: 'test',
+ summary: 'Hmm'},
+ {name: 'projects/a-project', displayName: 'a-project',
+ summary: 'I am Monkeyrail'},
+ ]);
+ });
+
+ it('renders user projects', async () => {
+ await element.updateComplete;
+
+ const projects = element.shadowRoot.querySelectorAll(
+ '.my-projects > .project');
+
+ assert.equal(projects.length, 2);
+ assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+ assert.include(projects[0].textContent, 'Best project ever');
+ assert.include(projects[0].querySelector('.subtitle').textContent,
+ 'Owner');
+
+ assert.include(projects[1].querySelector('h3').textContent, 'infra');
+ assert.include(projects[1].textContent, 'Make it work');
+ assert.include(projects[1].querySelector('.subtitle').textContent,
+ 'Committer');
+ });
+
+ it('renders other projects', async () => {
+ await element.updateComplete;
+
+ const projects = element.shadowRoot.querySelectorAll(
+ '.other-projects > .project');
+
+ assert.equal(projects.length, 2);
+ assert.include(projects[0].querySelector('h3').textContent, 'test');
+ assert.include(projects[0].textContent, 'Hmm');
+
+ assert.include(projects[1].querySelector('h3').textContent, 'a-project');
+ assert.include(projects[1].textContent, 'I am Monkeyrail');
+ });
+ });
+});
+
+describe('mr-projects-page (connected)', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+ sinon.spy(users, 'gatherProjectMemberships');
+ sinon.spy(stars, 'listProjects');
+
+ element = document.createElement('mr-projects-page');
+ });
+
+ afterEach(() => {
+ if (document.body.contains(element)) {
+ document.body.removeChild(element);
+ }
+
+ prpcClient.call.restore();
+ users.gatherProjectMemberships.restore();
+ stars.listProjects.restore();
+ });
+
+ it('fetches projects when connected', async () => {
+ const promise = Promise.resolve({
+ projects: [{name: 'projects/proj', displayName: 'proj',
+ summary: 'test'}],
+ });
+ prpcClient.call.returns(promise);
+
+ assert.isFalse(element._isFetchingProjects);
+ sinon.assert.notCalled(prpcClient.call);
+
+ // Trigger connectedCallback().
+ document.body.appendChild(element);
+ await stateUpdated, element.updateComplete;
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.v3.Projects',
+ 'ListProjects', {});
+
+ assert.isFalse(element._isFetchingProjects);
+ assert.deepEqual(element._projects,
+ [{name: 'projects/proj', displayName: 'proj',
+ summary: 'test'}]);
+ });
+
+ it('does not gather projects when user is logged out', async () => {
+ document.body.appendChild(element);
+ element._currentUser = '';
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(users.gatherProjectMemberships);
+ });
+
+ it('gathers user projects when user is logged in', async () => {
+ document.body.appendChild(element);
+ element._currentUser = 'users/1234';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(users.gatherProjectMemberships);
+ sinon.assert.calledWith(users.gatherProjectMemberships, 'users/1234');
+ });
+
+ it('does not fetch stars user is logged out', async () => {
+ document.body.appendChild(element);
+ element._currentUser = '';
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(stars.listProjects);
+ });
+
+ it('fetches stars when user is logged in', async () => {
+ document.body.appendChild(element);
+ element._currentUser = 'users/1234';
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(stars.listProjects);
+ sinon.assert.calledWith(stars.listProjects, 'users/1234');
+ });
+});