summaryrefslogtreecommitdiffstats
path: root/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
diff options
context:
space:
mode:
Diffstat (limited to 'polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts')
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts351
1 files changed, 351 insertions, 0 deletions
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
new file mode 100644
index 0000000000..85ea644e5d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -0,0 +1,351 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {LitElement, html, css, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import '../gr-change-list-action-bar/gr-change-list-action-bar';
+import {
+ CLOSED,
+ YOUR_TURN,
+ GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {getAppContext} from '../../../services/app-context';
+import {ChangeInfo, ServerInfo, AccountInfo} from '../../../api/rest-api';
+import {changeListStyles} from '../../../styles/gr-change-list-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {Metadata} from '../../../utils/change-metadata-util';
+import {WAITING} from '../../../constants/constants';
+import {ifDefined} from 'lit/directives/if-defined';
+import {provide} from '../../../models/dependency';
+import {
+ bulkActionsModelToken,
+ BulkActionsModel,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {subscribe} from '../../lit/subscription-controller';
+
+const NUMBER_FIXED_COLUMNS = 3;
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
+const INVALID_TOKENS = ['limit:', 'age:', '-age:'];
+
+export function computeLabelShortcut(labelName: string) {
+ if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+ labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+ }
+ // Compute label shortcut by splitting token by - and capitalizing first
+ // letter of each token.
+ return labelName
+ .split('-')
+ .reduce((previousValue, currentValue) => {
+ if (!currentValue) {
+ return previousValue;
+ }
+ return previousValue + currentValue[0].toUpperCase();
+ }, '')
+ .slice(0, MAX_SHORTCUT_CHARS);
+}
+
+@customElement('gr-change-list-section')
+export class GrChangeListSection extends LitElement {
+ @property({type: Array})
+ visibleChangeTableColumns?: string[];
+
+ @property({type: Boolean})
+ showStar = false;
+
+ @property({type: Boolean})
+ showNumber?: boolean; // No default value to prevent flickering.
+
+ @property({type: Number})
+ selectedIndex?: number; // The relative index of the change that is selected
+
+ @property({type: Array})
+ labelNames: string[] = [];
+
+ @property({type: Array})
+ dynamicHeaderEndpoints?: string[];
+
+ @property({type: Object})
+ changeSection!: ChangeListSection;
+
+ @property({type: Object})
+ config?: ServerInfo;
+
+ @property({type: Boolean})
+ isCursorMoving = false;
+
+ /**
+ * The logged-in user's account, or an empty object if no user is logged
+ * in.
+ */
+ @property({type: Object})
+ account: AccountInfo | undefined = undefined;
+
+ @state() showBulkActionsHeader = false;
+
+ private readonly flagsService = getAppContext().flagsService;
+
+ bulkActionsModel: BulkActionsModel = new BulkActionsModel(
+ getAppContext().restApiService
+ );
+
+ static override get styles() {
+ return [
+ changeListStyles,
+ fontStyles,
+ sharedStyles,
+ css`
+ :host {
+ display: contents;
+ }
+ .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);
+ }
+ `,
+ ];
+ }
+
+ constructor() {
+ super();
+ provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ subscribe(
+ this,
+ this.bulkActionsModel.selectedChangeNums$,
+ selectedChanges =>
+ (this.showBulkActionsHeader = selectedChanges.length > 0)
+ );
+ }
+
+ override willUpdate(changedProperties: PropertyValues) {
+ if (changedProperties.has('changeSection')) {
+ // In case the list of changes is updated due to auto reloading, we want
+ // to ensure the model removes any stale change that is not a part of the
+ // new section changes.
+ if (this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) {
+ this.bulkActionsModel.sync(this.changeSection.results);
+ }
+ }
+ }
+
+ override render() {
+ const columns = this.computeColumns();
+ const colSpan = this.computeColspan(columns);
+ return html`
+ ${this.renderSectionHeader(colSpan)}
+ <tbody class="groupContent">
+ ${this.isEmpty()
+ ? this.renderNoChangesRow(colSpan)
+ : this.renderColumnHeaders(columns)}
+ ${this.changeSection.results.map((change, index) =>
+ this.renderChangeRow(change, index, columns)
+ )}
+ </tbody>
+ `;
+ }
+
+ private renderNoChangesRow(colSpan: number) {
+ 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=${colSpan}>
+ ${this.changeSection.emptyStateSlotName
+ ? html`<slot name=${this.changeSection.emptyStateSlotName}></slot>`
+ : 'No changes'}
+ </td>
+ </tr>
+ `;
+ }
+
+ private renderSectionHeader(colSpan: number) {
+ if (
+ this.changeSection.name === undefined ||
+ this.changeSection.countLabel === undefined ||
+ this.changeSection.query === undefined
+ )
+ return;
+
+ return html`
+ <tbody>
+ <tr class="groupHeader">
+ <td aria-hidden="true" class="leftPadding"></td>
+ ${this.renderSelectionHeader()}
+ <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
+ <td class="cell" colspan=${colSpan}>
+ <h2 class="heading-3">
+ <a
+ href=${this.sectionHref(this.changeSection.query)}
+ class="section-title"
+ >
+ <span class="section-name">${this.changeSection.name}</span>
+ <span class="section-count-label"
+ >${this.changeSection.countLabel}</span
+ >
+ </a>
+ </h2>
+ </td>
+ </tr>
+ </tbody>
+ `;
+ }
+
+ private renderColumnHeaders(columns: string[]) {
+ return html`
+ <tr class="groupTitle">
+ ${this.showBulkActionsHeader &&
+ this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)
+ ? html`<gr-change-list-action-bar></gr-change-list-action-bar>`
+ : html` <td class="leftPadding" aria-hidden="true"></td>
+ ${this.renderSelectionHeader()}
+ <td
+ class="star"
+ aria-label="Star status column"
+ ?hidden=${!this.showStar}
+ ></td>
+ <td class="number" ?hidden=${!this.showNumber}>#</td>
+ ${columns.map(item => this.renderHeaderCell(item))}
+ ${this.labelNames?.map(labelName =>
+ this.renderLabelHeader(labelName)
+ )}
+ ${this.dynamicHeaderEndpoints?.map(pluginHeader =>
+ this.renderEndpointHeader(pluginHeader)
+ )}`}
+ </tr>
+ `;
+ }
+
+ private renderSelectionHeader() {
+ if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
+ return html`<td aria-hidden="true" class="selection"></td>`;
+ }
+
+ private renderHeaderCell(item: string) {
+ return html`<td class=${item.toLowerCase()}>${item}</td>`;
+ }
+
+ private renderLabelHeader(labelName: string) {
+ return html`
+ <td class="label" title=${labelName}>
+ ${computeLabelShortcut(labelName)}
+ </td>
+ `;
+ }
+
+ private renderEndpointHeader(pluginHeader: string) {
+ return html`
+ <td class="endpoint">
+ <gr-endpoint-decorator .name=${pluginHeader}></gr-endpoint-decorator>
+ </td>
+ `;
+ }
+
+ private renderChangeRow(
+ change: ChangeInfo,
+ index: number,
+ columns: string[]
+ ) {
+ const ariaLabel = this.computeAriaLabel(change);
+ const selected = this.computeItemSelected(index);
+ const tabindex = this.computeTabIndex(index);
+ return html`
+ <gr-change-list-item
+ .account=${this.account}
+ ?selected=${selected}
+ .change=${change}
+ .config=${this.config}
+ .sectionName=${this.changeSection.name}
+ .visibleChangeTableColumns=${columns}
+ .showNumber=${this.showNumber}
+ ?showStar=${this.showStar}
+ tabindex=${ifDefined(tabindex)}
+ .labelNames=${this.labelNames}
+ aria-label=${ariaLabel}
+ ></gr-change-list-item>
+ `;
+ }
+
+ /**
+ * This methods allows us to customize the columns per section.
+ * Private but used in test
+ *
+ */
+ computeColumns() {
+ const section = this.changeSection;
+ if (!section || !this.visibleChangeTableColumns) return [];
+ const cols = [...this.visibleChangeTableColumns];
+ const updatedIndex = cols.indexOf(Metadata.UPDATED);
+ if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+ cols[updatedIndex] = WAITING;
+ }
+ if (section.name === CLOSED.name && updatedIndex !== -1) {
+ cols[updatedIndex] = Metadata.SUBMITTED;
+ }
+ return cols;
+ }
+
+ // private but used in test
+ computeItemSelected(index: number) {
+ return index === this.selectedIndex;
+ }
+
+ private computeTabIndex(index: number) {
+ if (this.isCursorMoving) return 0;
+ return this.computeItemSelected(index) ? 0 : undefined;
+ }
+
+ // private but used in test
+ computeColspan(cols: string[]) {
+ if (!cols || !this.labelNames) return 1;
+ return cols.length + this.labelNames.length + NUMBER_FIXED_COLUMNS;
+ }
+
+ // private but used in test
+ processQuery(query: string) {
+ let tokens = query.split(' ');
+ tokens = tokens.filter(
+ token =>
+ !INVALID_TOKENS.some(invalidToken => token.startsWith(invalidToken))
+ );
+ return tokens.join(' ');
+ }
+
+ private sectionHref(query?: string) {
+ if (!query) return;
+ return GerritNav.getUrlForSearchQuery(this.processQuery(query));
+ }
+
+ // private but used in test
+ isEmpty() {
+ return !this.changeSection.results?.length;
+ }
+
+ private computeAriaLabel(change?: ChangeInfo) {
+ const sectionName = this.changeSection.name;
+ if (!change) return '';
+ return change.subject + (sectionName ? `, section: ${sectionName}` : '');
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list-section': GrChangeListSection;
+ }
+}