Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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');
+ });
+});