| /** |
| * @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<{}>; |
| } |
| } |