|  | /** | 
|  | * @license | 
|  | * Copyright 2015 Google LLC | 
|  | * SPDX-License-Identifier: Apache-2.0 | 
|  | */ | 
|  | import '../../shared/gr-cursor-manager/gr-cursor-manager'; | 
|  | import '../gr-change-list-item/gr-change-list-item'; | 
|  | import '../gr-change-list-section/gr-change-list-section'; | 
|  | 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 {navigationToken} from '../../core/gr-navigation/gr-navigation'; | 
|  | import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager'; | 
|  | import { | 
|  | AccountInfo, | 
|  | ChangeInfo, | 
|  | ServerInfo, | 
|  | PreferencesInput, | 
|  | } from '../../../types/common'; | 
|  | import {fire, fireReload} from '../../../utils/event-util'; | 
|  | import {ColumnNames, ScrollMode} from '../../../constants/constants'; | 
|  | import {getRequirements} from '../../../utils/label-util'; | 
|  | import {Key} from '../../../utils/dom-util'; | 
|  | import {assertIsDefined, 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, nothing} from 'lit'; | 
|  | import {customElement, property, state} from 'lit/decorators.js'; | 
|  | import {Shortcut, ShortcutController} from '../../lit/shortcut-controller'; | 
|  | import {queryAll} from '../../../utils/common-util'; | 
|  | import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section'; | 
|  | import {Execution} from '../../../constants/reporting'; | 
|  | import {ValueChangedEvent} from '../../../types/events'; | 
|  | import {resolve} from '../../../models/dependency'; | 
|  | import {createChangeUrl} from '../../../models/views/change'; | 
|  | import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; | 
|  |  | 
|  | export interface ChangeListSection { | 
|  | countLabel?: string; | 
|  | emptyStateSlotName?: string; | 
|  | name?: string; | 
|  | query?: string; | 
|  | results: ChangeInfo[]; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Calculate the relative index of the currently selected change wrt to the | 
|  | * section it belongs to. | 
|  | * The 10th change in the overall list may be the 4th change in it's section | 
|  | * so this method maps 10 to 4. | 
|  | * selectedIndex contains the index of the change wrt the entire change list. | 
|  | * Private but used in test | 
|  | * | 
|  | */ | 
|  | export function computeRelativeIndex( | 
|  | selectedIndex?: number, | 
|  | sectionIndex?: number, | 
|  | sections?: ChangeListSection[] | 
|  | ) { | 
|  | if ( | 
|  | selectedIndex === undefined || | 
|  | sectionIndex === undefined || | 
|  | sections === undefined | 
|  | ) | 
|  | return; | 
|  | for (let i = 0; i < sectionIndex; i++) | 
|  | selectedIndex -= sections[i].results.length; | 
|  | if (selectedIndex < 0) return; // selected change lies in previous sections | 
|  |  | 
|  | // the selectedIndex lies in the current section | 
|  | if (selectedIndex < sections[sectionIndex].results.length) | 
|  | return selectedIndex; | 
|  | return; // selected change lies in future sections | 
|  | } | 
|  |  | 
|  | @customElement('gr-change-list') | 
|  | export class GrChangeList extends LitElement { | 
|  | /** | 
|  | * 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 = 0; | 
|  |  | 
|  | @property({type: Boolean}) | 
|  | showNumber?: boolean; // No default value to prevent flickering. | 
|  |  | 
|  | @property({type: Boolean}) | 
|  | showReviewedState = false; | 
|  |  | 
|  | @property({type: Array}) | 
|  | changeTableColumns?: string[]; | 
|  |  | 
|  | @property({type: String}) | 
|  | usp?: 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 reporting = getAppContext().reportingService; | 
|  |  | 
|  | private readonly shortcuts = new ShortcutController(this); | 
|  |  | 
|  | private readonly getPluginLoader = resolve(this, pluginLoaderToken); | 
|  |  | 
|  | private readonly getNavigation = resolve(this, navigationToken); | 
|  |  | 
|  | 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() | 
|  | ); | 
|  | this.shortcuts.addAbstract(Shortcut.TOGGLE_CHECKBOX, () => | 
|  | this.toggleCheckbox() | 
|  | ); | 
|  | this.shortcuts.addGlobal({key: Key.ENTER}, () => this.openChange()); | 
|  | } | 
|  |  | 
|  | override connectedCallback() { | 
|  | super.connectedCallback(); | 
|  | this.restApiService.getConfig().then(config => { | 
|  | this.config = config; | 
|  | }); | 
|  | this.getPluginLoader() | 
|  | .awaitPluginsLoaded() | 
|  | .then(() => { | 
|  | this.dynamicHeaderEndpoints = | 
|  | this.getPluginLoader().pluginEndPoints.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() { | 
|  | if (!this.sections) return; | 
|  | const labelNames = this.computeLabelNames(this.sections); | 
|  | const startIndices = this.calculateStartIndices(this.sections); | 
|  | return html` | 
|  | <table id="changeList"> | 
|  | ${this.sections.map((changeSection, sectionIndex) => | 
|  | this.renderSection( | 
|  | changeSection, | 
|  | sectionIndex, | 
|  | labelNames, | 
|  | startIndices[sectionIndex] | 
|  | ) | 
|  | )} | 
|  | </table> | 
|  | `; | 
|  | } | 
|  |  | 
|  | private calculateStartIndices(sections: ChangeListSection[]): number[] { | 
|  | const startIndices = Array.from<number>({length: sections.length}).fill(0); | 
|  | for (let i = 1; i < sections.length; ++i) { | 
|  | startIndices[i] = startIndices[i - 1] + sections[i - 1].results.length; | 
|  | } | 
|  | return startIndices; | 
|  | } | 
|  |  | 
|  | private renderSection( | 
|  | changeSection: ChangeListSection, | 
|  | sectionIndex: number, | 
|  | labelNames: string[], | 
|  | startIndex: number | 
|  | ) { | 
|  | return html` | 
|  | <gr-change-list-section | 
|  | .changeSection=${changeSection} | 
|  | .labelNames=${labelNames} | 
|  | .dynamicHeaderEndpoints=${this.dynamicHeaderEndpoints} | 
|  | .isCursorMoving=${this.isCursorMoving} | 
|  | .config=${this.config} | 
|  | .account=${this.account} | 
|  | .selectedIndex=${computeRelativeIndex( | 
|  | this.selectedIndex, | 
|  | sectionIndex, | 
|  | this.sections | 
|  | )} | 
|  | .showNumber=${this.showNumber} | 
|  | .visibleChangeTableColumns=${this.visibleChangeTableColumns} | 
|  | .usp=${this.usp} | 
|  | .startIndex=${startIndex} | 
|  | .triggerSelectionCallback=${(index: number) => { | 
|  | this.selectedIndex = index; | 
|  | this.cursor.setCursorAtIndex(this.selectedIndex); | 
|  | }} | 
|  | > | 
|  | ${changeSection.emptyStateSlotName | 
|  | ? html`<slot | 
|  | slot=${changeSection.emptyStateSlotName} | 
|  | name=${changeSection.emptyStateSlotName} | 
|  | ></slot>` | 
|  | : nothing} | 
|  | </gr-change-list-section> | 
|  | `; | 
|  | } | 
|  |  | 
|  | override willUpdate(changedProperties: PropertyValues) { | 
|  | if ( | 
|  | changedProperties.has('account') || | 
|  | changedProperties.has('preferences') || | 
|  | changedProperties.has('config') || | 
|  | changedProperties.has('sections') | 
|  | ) { | 
|  | this.computeVisibleChangeTableColumns(); | 
|  | } | 
|  |  | 
|  | if (changedProperties.has('changes')) { | 
|  | this.changesChanged(); | 
|  | } | 
|  | } | 
|  |  | 
|  | override updated(changedProperties: PropertyValues) { | 
|  | if (changedProperties.has('sections')) { | 
|  | this.sectionsChanged(); | 
|  | } | 
|  | if (changedProperties.has('selectedIndex')) { | 
|  | fire(this, 'selected-index-changed', { | 
|  | value: this.selectedIndex ?? 0, | 
|  | }); | 
|  | } | 
|  | } | 
|  |  | 
|  | private toggleCheckbox() { | 
|  | assertIsDefined(this.selectedIndex, 'selectedIndex'); | 
|  | let selectedIndex = this.selectedIndex; | 
|  | assertIsDefined(this.sections, 'sections'); | 
|  | const changeSections = queryAll<GrChangeListSection>( | 
|  | this, | 
|  | 'gr-change-list-section' | 
|  | ); | 
|  | for (let i = 0; i < this.sections.length; i++) { | 
|  | if (selectedIndex >= this.sections[i].results.length) { | 
|  | selectedIndex -= this.sections[i].results.length; | 
|  | continue; | 
|  | } | 
|  | changeSections[i].toggleChange(selectedIndex); | 
|  | return; | 
|  | } | 
|  | throw new Error('invalid selected index'); | 
|  | } | 
|  |  | 
|  | private computeVisibleChangeTableColumns() { | 
|  | if (!this.config) return; | 
|  |  | 
|  | this.changeTableColumns = Object.values(ColumnNames); | 
|  | this.showNumber = false; | 
|  | this.visibleChangeTableColumns = this.changeTableColumns.filter(col => | 
|  | this.isColumnEnabled(col, this.config) | 
|  | ); | 
|  | 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' ? ColumnNames.REPO : column)) | 
|  | .map(column => | 
|  | column === ColumnNames.STATUS ? ColumnNames.STATUS2 : column | 
|  | ); | 
|  | this.reporting.reportExecution(Execution.USER_PREFERENCES_COLUMNS, { | 
|  | statusColumn: prefColumns.includes(ColumnNames.STATUS2), | 
|  | }); | 
|  | // Order visible column names by columnNames, filter only one that | 
|  | // are in prefColumns and enabled by config | 
|  | this.visibleChangeTableColumns = Object.values(ColumnNames) | 
|  | .filter(col => prefColumns.includes(col)) | 
|  | .filter(col => this.isColumnEnabled(col, this.config)); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Is the column disabled by a server config or experiment? | 
|  | */ | 
|  | isColumnEnabled(column: string, config?: ServerInfo) { | 
|  | if (!Object.values(ColumnNames).includes(column as unknown as ColumnNames)) | 
|  | return false; | 
|  | if (!config || !config.change) return true; | 
|  | if (column === 'Comments') | 
|  | return this.flagsService.isEnabled('comments-column'); | 
|  | if (column === 'Status') return false; | 
|  | if (column === ColumnNames.STATUS2) return true; | 
|  | return true; | 
|  | } | 
|  |  | 
|  | // private but used in test | 
|  | computeLabelNames(sections: ChangeListSection[]) { | 
|  | if (!sections) return []; | 
|  | if (this.config?.submit_requirement_dashboard_columns?.length) { | 
|  | return this.config?.submit_requirement_dashboard_columns; | 
|  | } | 
|  | const changes = sections.map(section => section.results).flat(); | 
|  | const labels = (changes ?? []) | 
|  | .map(change => getRequirements(change)) | 
|  | .flat() | 
|  | .map(requirement => requirement.name) | 
|  | .filter(unique); | 
|  | return labels.sort(); | 
|  | } | 
|  |  | 
|  | private changesChanged() { | 
|  | this.sections = this.changes ? [{results: this.changes}] : []; | 
|  | } | 
|  |  | 
|  | private nextChange() { | 
|  | this.isCursorMoving = true; | 
|  | this.cursor.next(); | 
|  | this.isCursorMoving = false; | 
|  | this.selectedIndex = this.cursor.index; | 
|  | } | 
|  |  | 
|  | private prevChange() { | 
|  | this.isCursorMoving = true; | 
|  | this.cursor.previous(); | 
|  | this.isCursorMoving = false; | 
|  | this.selectedIndex = this.cursor.index; | 
|  | } | 
|  |  | 
|  | private async openChange() { | 
|  | const change = await this.changeForIndex(this.selectedIndex); | 
|  | if (change) this.getNavigation().setUrl(createChangeUrl({change})); | 
|  | } | 
|  |  | 
|  | private nextPage() { | 
|  | fire(this, 'next-page', {}); | 
|  | } | 
|  |  | 
|  | private prevPage() { | 
|  | fire(this, 'previous-page', {}); | 
|  | } | 
|  |  | 
|  | private refreshChangeList() { | 
|  | fireReload(this); | 
|  | } | 
|  |  | 
|  | private toggleChangeStar() { | 
|  | this.toggleStarForIndex(this.selectedIndex); | 
|  | } | 
|  |  | 
|  | private async toggleStarForIndex(index?: number) { | 
|  | const changeEls = await 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 async changeForIndex(index?: number) { | 
|  | const changeEls = await this.getListItems(); | 
|  | if (index !== undefined && index < changeEls.length && changeEls[index]) { | 
|  | return changeEls[index].change; | 
|  | } | 
|  | return null; | 
|  | } | 
|  |  | 
|  | // Private but used in tests | 
|  | async getListItems() { | 
|  | const items: GrChangeListItem[] = []; | 
|  | const sections = queryAll<GrChangeListSection>( | 
|  | this, | 
|  | 'gr-change-list-section' | 
|  | ); | 
|  | await Promise.all(Array.from(sections).map(s => s.updateComplete)); | 
|  | for (const section of sections) { | 
|  | // getListItems() is triggered when sectionsChanged observer is triggered | 
|  | // In some cases <gr-change-list-item> has not been attached to the DOM | 
|  | // yet and hence queryAll returns [] | 
|  | // Once the items have been attached, sectionsChanged() is not called | 
|  | // again and the cursor stops are not updated to have the correct value | 
|  | // hence wait for section to render before querying for items | 
|  | const res = queryAll<GrChangeListItem>(section, 'gr-change-list-item'); | 
|  | items.push(...res); | 
|  | } | 
|  | return items; | 
|  | } | 
|  |  | 
|  | // Private but used in tests | 
|  | async sectionsChanged() { | 
|  | this.cursor.stops = await this.getListItems(); | 
|  | this.cursor.moveToStart(); | 
|  | if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex); | 
|  | } | 
|  | } | 
|  |  | 
|  | declare global { | 
|  | interface HTMLElementTagNameMap { | 
|  | 'gr-change-list': GrChangeList; | 
|  | } | 
|  | interface HTMLElementEventMap { | 
|  | 'selected-index-changed': ValueChangedEvent<number>; | 
|  | /** Fired when next page key shortcut was pressed. */ | 
|  | 'next-page': CustomEvent<{}>; | 
|  | /** Fired when previous page key shortcut was pressed. */ | 
|  | 'previous-page': CustomEvent<{}>; | 
|  | } | 
|  | } |