blob: dfe4720bc788dd8996a5e072fc49318bad54bed9 [file] [log] [blame]
/**
* @license
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../../shared/gr-cursor-manager/gr-cursor-manager';
import '../gr-change-list-item/gr-change-list-item';
import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import {getAppContext} from '../../../services/app-context';
import {
GerritNav,
YOUR_TURN,
CLOSED,
} from '../../core/gr-navigation/gr-navigation';
import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
import {
AccountInfo,
ChangeInfo,
ServerInfo,
PreferencesInput,
} from '../../../types/common';
import {fire, fireEvent, fireReload} from '../../../utils/event-util';
import {ScrollMode} from '../../../constants/constants';
import {
getRequirements,
showNewSubmitRequirements,
} from '../../../utils/label-util';
import {addGlobalShortcut, Key} from '../../../utils/dom-util';
import {unique} from '../../../utils/common-util';
import {changeListStyles} from '../../../styles/gr-change-list-styles';
import {fontStyles} from '../../../styles/gr-font-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {ShortcutController} from '../../lit/shortcut-controller';
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {queryAll} from '../../../utils/common-util';
import {ValueChangedEvent} from '../../../types/events';
const NUMBER_FIXED_COLUMNS = 3;
const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
const MAX_SHORTCUT_CHARS = 5;
export const columnNames = [
'Subject',
// TODO(milutin) - remove once Submit Requirements are rolled out.
'Status',
'Owner',
'Reviewers',
'Comments',
'Repo',
'Branch',
'Updated',
'Size',
' Status ', // spaces to differentiate from old 'Status'
];
export interface ChangeListSection {
countLabel?: string;
isOutgoing?: boolean;
name?: string;
query?: string;
results: ChangeInfo[];
}
@customElement('gr-change-list')
export class GrChangeList extends LitElement {
/**
* Fired when next page key shortcut was pressed.
*
* @event next-page
*/
/**
* Fired when previous page key shortcut was pressed.
*
* @event previous-page
*/
/**
* The logged-in user's account, or an empty object if no user is logged
* in.
*/
@property({type: Object})
account: AccountInfo | undefined = undefined;
@property({type: Array})
changes?: ChangeInfo[];
/**
* ChangeInfo objects grouped into arrays. The sections and changes
* properties should not be used together.
*/
@property({type: Array})
sections: ChangeListSection[] = [];
@state() private dynamicHeaderEndpoints?: string[];
@property({type: Number})
selectedIndex?: number;
@property({type: Boolean})
showNumber?: boolean; // No default value to prevent flickering.
@property({type: Boolean})
showStar = false;
@property({type: Boolean})
showReviewedState = false;
@property({type: Array})
changeTableColumns?: string[];
@property({type: Array})
visibleChangeTableColumns?: string[];
@property({type: Object})
preferences?: PreferencesInput;
@property({type: Boolean})
isCursorMoving = false;
// private but used in test
@state() config?: ServerInfo;
private readonly flagsService = getAppContext().flagsService;
private readonly restApiService = getAppContext().restApiService;
private readonly shortcuts = new ShortcutController(this);
private cursor = new GrCursorManager();
constructor() {
super();
this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
this.cursor.focusOnMove = true;
this.shortcuts.addAbstract(Shortcut.CURSOR_NEXT_CHANGE, () =>
this.nextChange()
);
this.shortcuts.addAbstract(Shortcut.CURSOR_PREV_CHANGE, () =>
this.prevChange()
);
this.shortcuts.addAbstract(Shortcut.NEXT_PAGE, () => this.nextPage());
this.shortcuts.addAbstract(Shortcut.PREV_PAGE, () => this.prevPage());
this.shortcuts.addAbstract(Shortcut.OPEN_CHANGE, () => this.openChange());
this.shortcuts.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, () =>
this.toggleChangeStar()
);
this.shortcuts.addAbstract(Shortcut.REFRESH_CHANGE_LIST, () =>
this.refreshChangeList()
);
addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
}
override connectedCallback() {
super.connectedCallback();
this.restApiService.getConfig().then(config => {
this.config = config;
});
getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this.dynamicHeaderEndpoints =
getPluginEndpoints().getDynamicEndpoints('change-list-header');
});
}
override disconnectedCallback() {
this.cursor.unsetCursor();
super.disconnectedCallback();
}
static override get styles() {
return [
changeListStyles,
fontStyles,
sharedStyles,
css`
#changeList {
border-collapse: collapse;
width: 100%;
}
.section-count-label {
color: var(--deemphasized-text-color);
font-family: var(--font-family);
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-small);
}
a.section-title:hover {
text-decoration: none;
}
a.section-title:hover .section-count-label {
text-decoration: none;
}
a.section-title:hover .section-name {
text-decoration: underline;
}
`,
];
}
override render() {
const labelNames = this.computeLabelNames(this.sections);
return html`
<table id="changeList">
${this.sections.map((changeSection, sectionIndex) =>
this.renderSections(changeSection, sectionIndex, labelNames)
)}
</table>
`;
}
private renderSections(
changeSection: ChangeListSection,
sectionIndex: number,
labelNames: string[]
) {
return html`
${this.renderSectionHeader(changeSection, labelNames)}
<tbody class="groupContent">
${this.isEmpty(changeSection)
? this.renderNoChangesRow(changeSection, labelNames)
: this.renderColumnHeaders(changeSection, labelNames)}
${changeSection.results.map((change, index) =>
this.renderChangeRow(
changeSection,
change,
index,
sectionIndex,
labelNames
)
)}
</tbody>
`;
}
private renderSectionHeader(
changeSection: ChangeListSection,
labelNames: string[]
) {
if (!changeSection.name) return;
return html`
<tbody>
<tr class="groupHeader">
<td aria-hidden="true" class="leftPadding"></td>
<td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
<td
class="cell"
colspan="${this.computeColspan(changeSection, labelNames)}"
>
<h2 class="heading-3">
<a
href="${this.sectionHref(changeSection.query)}"
class="section-title"
>
<span class="section-name">${changeSection.name}</span>
<span class="section-count-label"
>${changeSection.countLabel}</span
>
</a>
</h2>
</td>
</tr>
</tbody>
`;
}
private renderNoChangesRow(
changeSection: ChangeListSection,
labelNames: string[]
) {
return html`
<tr class="noChanges">
<td class="leftPadding" aria-hidden="true"></td>
<td
class="star"
?aria-hidden=${!this.showStar}
?hidden=${!this.showStar}
></td>
<td
class="cell"
colspan="${this.computeColspan(changeSection, labelNames)}"
>
${this.getSpecialEmptySlot(changeSection)
? html`<slot
name="${this.getSpecialEmptySlot(changeSection)}"
></slot>`
: 'No changes'}
</td>
</tr>
`;
}
private renderColumnHeaders(
changeSection: ChangeListSection,
labelNames: string[]
) {
return html`
<tr class="groupTitle">
<td class="leftPadding" aria-hidden="true"></td>
<td
class="star"
aria-label="Star status column"
?hidden=${!this.showStar}
></td>
<td class="number" ?hidden=${!this.showNumber}>#</td>
${this.computeColumns(changeSection).map(item =>
this.renderHeaderCell(item)
)}
${labelNames?.map(labelName => this.renderLabelHeader(labelName))}
${this.dynamicHeaderEndpoints?.map(pluginHeader =>
this.renderEndpointHeader(pluginHeader)
)}
</tr>
`;
}
private renderHeaderCell(item: string) {
return html`<td class="${item.toLowerCase()}">${item}</td>`;
}
private renderLabelHeader(labelName: string) {
return html`
<td class="label" title="${labelName}">
${this.computeLabelShortcut(labelName)}
</td>
`;
}
private renderEndpointHeader(pluginHeader: string) {
return html`
<td class="endpoint">
<gr-endpoint-decorator .name="${pluginHeader}"></gr-endpoint-decorator>
</td>
`;
}
private renderChangeRow(
changeSection: ChangeListSection,
change: ChangeInfo,
index: number,
sectionIndex: number,
labelNames: string[]
) {
const ariaLabel = this.computeAriaLabel(change, changeSection.name);
const selected = this.computeItemSelected(
sectionIndex,
index,
this.selectedIndex
);
const tabindex = this.computeTabIndex(
sectionIndex,
index,
this.isCursorMoving,
this.selectedIndex
);
const visibleChangeTableColumns = this.computeColumns(changeSection);
return html`
<gr-change-list-item
.account=${this.account}
?selected=${selected}
.change=${change}
.config=${this.config}
.sectionName=${changeSection.name}
.visibleChangeTableColumns=${visibleChangeTableColumns}
.showNumber=${this.showNumber}
.showStar=${this.showStar}
?tabindex=${tabindex}
.labelNames=${labelNames}
aria-label=${ariaLabel}
></gr-change-list-item>
`;
}
override willUpdate(changedProperties: PropertyValues) {
if (
changedProperties.has('account') ||
changedProperties.has('preferences') ||
changedProperties.has('config') ||
changedProperties.has('sections')
) {
this.computePreferences();
}
if (changedProperties.has('changes')) {
this.changesChanged();
}
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('sections')) {
this.sectionsChanged();
}
}
private computePreferences() {
if (!this.config) return;
const changes = (this.sections ?? [])
.map(section => section.results)
.flat();
this.changeTableColumns = columnNames;
this.showNumber = false;
this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
this._isColumnEnabled(col, this.config, changes)
);
if (this.account && this.preferences) {
this.showNumber = !!this.preferences?.legacycid_in_change_table;
if (
this.preferences?.change_table &&
this.preferences.change_table.length > 0
) {
const prefColumns = this.preferences.change_table.map(column =>
column === 'Project' ? 'Repo' : column
);
this.visibleChangeTableColumns = prefColumns.filter(col =>
this._isColumnEnabled(col, this.config, changes)
);
}
}
}
/**
* Is the column disabled by a server config or experiment?
*/
_isColumnEnabled(
column: string,
config?: ServerInfo,
changes?: ChangeInfo[]
) {
if (!columnNames.includes(column)) return false;
if (!config || !config.change) return true;
if (column === 'Comments')
return this.flagsService.isEnabled('comments-column');
if (column === 'Status') {
return (changes ?? []).every(
change => !showNewSubmitRequirements(this.flagsService, change)
);
}
if (column === ' Status ')
return (changes ?? []).some(change =>
showNewSubmitRequirements(this.flagsService, change)
);
return true;
}
/**
* This methods allows us to customize the columns per section.
*
* @param visibleColumns are the columns according to configs and user prefs
*/
private computeColumns(section?: ChangeListSection): string[] {
if (!section || !this.visibleChangeTableColumns) return [];
const cols = [...this.visibleChangeTableColumns];
const updatedIndex = cols.indexOf('Updated');
if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
cols[updatedIndex] = 'Waiting';
}
if (section.name === CLOSED.name && updatedIndex !== -1) {
cols[updatedIndex] = 'Submitted';
}
return cols;
}
// private but used in test
computeColspan(section?: ChangeListSection, labelNames?: string[]) {
const cols = this.computeColumns(section);
if (!cols || !labelNames) return 1;
return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
}
// private but used in test
computeLabelNames(sections: ChangeListSection[]) {
if (!sections) return [];
let labels: string[] = [];
const nonExistingLabel = function (item: string) {
return !labels.includes(item);
};
for (const section of sections) {
if (!section.results) {
continue;
}
for (const change of section.results) {
if (!change.labels) {
continue;
}
const currentLabels = Object.keys(change.labels);
labels = labels.concat(currentLabels.filter(nonExistingLabel));
}
}
const changes = sections.map(section => section.results).flat();
if (
(changes ?? []).some(change =>
showNewSubmitRequirements(this.flagsService, change)
)
) {
labels = (changes ?? [])
.map(change => getRequirements(change))
.flat()
.map(requirement => requirement.name)
.filter(unique);
}
return labels.sort();
}
// private but used in test
computeLabelShortcut(labelName: string) {
if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
}
return labelName
.split('-')
.reduce((a, i) => {
if (!i) {
return a;
}
return a + i[0].toUpperCase();
}, '')
.slice(0, MAX_SHORTCUT_CHARS);
}
private changesChanged() {
this.sections = this.changes ? [{results: this.changes}] : [];
}
// private but used in test
processQuery(query: string) {
let tokens = query.split(' ');
const invalidTokens = ['limit:', 'age:', '-age:'];
tokens = tokens.filter(
token =>
!invalidTokens.some(invalidToken => token.startsWith(invalidToken))
);
return tokens.join(' ');
}
private sectionHref(query?: string) {
if (!query) return;
return GerritNav.getUrlForSearchQuery(this.processQuery(query));
}
/**
* Maps an index local to a particular section to the absolute index
* across all the changes on the page.
*
* private but used in test
*
* @param sectionIndex index of section
* @param localIndex index of row within section
* @return absolute index of row in the aggregate dashboard
*/
computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
let idx = 0;
for (let i = 0; i < sectionIndex; i++) {
idx += this.sections[i].results.length;
}
return idx + localIndex;
}
private computeItemSelected(
sectionIndex: number,
index: number,
selectedIndex?: number
) {
const idx = this.computeItemAbsoluteIndex(sectionIndex, index);
return idx === selectedIndex;
}
private computeTabIndex(
sectionIndex: number,
index: number,
isCursorMoving: boolean,
selectedIndex?: number
) {
if (isCursorMoving) return 0;
return this.computeItemSelected(sectionIndex, index, selectedIndex)
? 0
: undefined;
}
private nextChange() {
this.isCursorMoving = true;
this.cursor.next();
this.isCursorMoving = false;
this.selectedIndex = this.cursor.index;
fire(this, 'selected-index-changed', {value: this.cursor.index});
}
private prevChange() {
this.isCursorMoving = true;
this.cursor.previous();
this.isCursorMoving = false;
this.selectedIndex = this.cursor.index;
fire(this, 'selected-index-changed', {value: this.cursor.index});
}
private openChange() {
const change = this.changeForIndex(this.selectedIndex);
if (change) GerritNav.navigateToChange(change);
}
private nextPage() {
fireEvent(this, 'next-page');
}
private prevPage() {
fireEvent(this, 'previous-page');
}
private refreshChangeList() {
fireReload(this);
}
private toggleChangeStar() {
this.toggleStarForIndex(this.selectedIndex);
}
private toggleStarForIndex(index?: number) {
const changeEls = this.getListItems();
if (index === undefined || index >= changeEls.length || !changeEls[index]) {
return;
}
const changeEl = changeEls[index];
const grChangeStar = changeEl?.shadowRoot?.querySelector('gr-change-star');
if (grChangeStar) grChangeStar.toggleStar();
}
private changeForIndex(index?: number) {
const changeEls = this.getListItems();
if (index !== undefined && index < changeEls.length && changeEls[index]) {
return changeEls[index].change;
}
return null;
}
private getListItems() {
const items = queryAll<GrChangeListItem>(this, 'gr-change-list-item');
return !items ? [] : Array.from(items);
}
private sectionsChanged() {
this.cursor.stops = this.getListItems();
this.cursor.moveToStart();
if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
}
// private but used in test
getSpecialEmptySlot(section: ChangeListSection) {
if (section.isOutgoing) return 'empty-outgoing';
if (section.name === YOUR_TURN.name) return 'empty-your-turn';
return '';
}
// private but used in test
isEmpty(section: ChangeListSection) {
return !section.results?.length;
}
private computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
if (!change) return '';
return change.subject + (sectionName ? `, section: ${sectionName}` : '');
}
}
declare global {
interface HTMLElementEventMap {
'selected-index-changed': ValueChangedEvent<number>;
}
interface HTMLElementTagNameMap {
'gr-change-list': GrChangeList;
}
}