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