| /** |
| * @license |
| * Copyright 2015 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../../shared/gr-account-label/gr-account-label'; |
| import '../../shared/gr-change-star/gr-change-star'; |
| import '../../shared/gr-change-status/gr-change-status'; |
| import '../../shared/gr-date-formatter/gr-date-formatter'; |
| import '../../shared/gr-icon/gr-icon'; |
| import '../../shared/gr-limited-text/gr-limited-text'; |
| import '../../shared/gr-tooltip-content/gr-tooltip-content'; |
| import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; |
| import '../../plugins/gr-endpoint-param/gr-endpoint-param'; |
| import '../gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary'; |
| import '../gr-change-list-column-requirement/gr-change-list-column-requirement'; |
| import '../../shared/gr-tooltip-content/gr-tooltip-content'; |
| import {navigationToken} from '../../core/gr-navigation/gr-navigation'; |
| import {getDisplayName} from '../../../utils/display-name-util'; |
| import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints'; |
| import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {truncatePath} from '../../../utils/path-list-util'; |
| import {changeStatuses} from '../../../utils/change-util'; |
| import {isSelf, isServiceUser} from '../../../utils/account-util'; |
| import {ReportingService} from '../../../services/gr-reporting/gr-reporting'; |
| import { |
| ChangeInfo, |
| ServerInfo, |
| AccountInfo, |
| Timestamp, |
| } from '../../../types/common'; |
| import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util'; |
| import {changeListStyles} from '../../../styles/gr-change-list-styles'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {LitElement, css, html, PropertyValues} from 'lit'; |
| import {customElement, property, state} from 'lit/decorators.js'; |
| import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles'; |
| import {ifDefined} from 'lit/directives/if-defined.js'; |
| import {ChangeStatus, ColumnNames, WAITING} from '../../../constants/constants'; |
| import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model'; |
| import {resolve} from '../../../models/dependency'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {classMap} from 'lit/directives/class-map.js'; |
| import {createSearchUrl} from '../../../models/views/search'; |
| import {createChangeUrl} from '../../../models/views/change'; |
| |
| enum ChangeSize { |
| XS = 10, |
| SMALL = 50, |
| MEDIUM = 250, |
| LARGE = 1000, |
| } |
| |
| // export for testing |
| export enum LabelCategory { |
| NOT_APPLICABLE = 'NOT_APPLICABLE', |
| APPROVED = 'APPROVED', |
| POSITIVE = 'POSITIVE', |
| NEUTRAL = 'NEUTRAL', |
| UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS', |
| NEGATIVE = 'NEGATIVE', |
| REJECTED = 'REJECTED', |
| } |
| |
| // How many reviewers should be shown with an account-label? |
| const PRIMARY_REVIEWERS_COUNT = 2; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-change-list-item': GrChangeListItem; |
| } |
| } |
| |
| @customElement('gr-change-list-item') |
| export class GrChangeListItem extends LitElement { |
| /** The logged-in user's account, or null if no user is logged in. */ |
| @property({type: Object}) |
| account: AccountInfo | null = null; |
| |
| @property({type: Array}) |
| visibleChangeTableColumns?: string[]; |
| |
| @property({type: Array}) |
| labelNames?: string[]; |
| |
| @property({type: Object}) |
| change?: ChangeInfo; |
| |
| @property({type: Object}) |
| config?: ServerInfo; |
| |
| /** Name of the section in the change-list. Used for reporting. */ |
| @property({type: String}) |
| sectionName?: string; |
| |
| @property({type: Boolean}) |
| showStar = false; |
| |
| @property({type: Boolean}) |
| showNumber = false; |
| |
| @property({type: String}) |
| usp?: string; |
| |
| @property({type: Boolean, reflect: true}) selected = false; |
| |
| // private but used in tests |
| @property({type: Boolean, reflect: true}) checked = false; |
| |
| @state() private dynamicCellEndpoints?: string[]; |
| |
| reporting: ReportingService = getAppContext().reportingService; |
| |
| private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken); |
| |
| private readonly getNavigation = resolve(this, navigationToken); |
| |
| constructor() { |
| super(); |
| subscribe( |
| this, |
| () => this.getBulkActionsModel().selectedChangeNums$, |
| selectedChangeNums => { |
| if (!this.change) return; |
| this.checked = selectedChangeNums.includes(this.change._number); |
| } |
| ); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| getPluginLoader() |
| .awaitPluginsLoaded() |
| .then(() => { |
| this.dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints( |
| 'change-list-item-cell' |
| ); |
| }); |
| this.addEventListener('click', this.onItemClick); |
| } |
| |
| override disconnectedCallback() { |
| this.removeEventListener('click', this.onItemClick); |
| } |
| |
| override willUpdate(changedProperties: PropertyValues<this>) { |
| // When the cursor selects this item, give it focus so that the item is read |
| // out by screen readers and lets users start tabbing through the item |
| if (this.selected && !changedProperties.get('selected')) { |
| this.focus(); |
| } |
| } |
| |
| static override get styles() { |
| return [ |
| changeListStyles, |
| sharedStyles, |
| submitRequirementsStyles, |
| css` |
| :host { |
| display: table-row; |
| color: var(--primary-text-color); |
| } |
| :host(:focus) { |
| outline: none; |
| } |
| :host([checked]), |
| :host(:hover) { |
| background-color: var(--hover-background-color); |
| } |
| .container { |
| position: relative; |
| } |
| .strikethrough { |
| color: var(--deemphasized-text-color); |
| text-decoration: line-through; |
| } |
| .content { |
| overflow: hidden; |
| position: absolute; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| width: 100%; |
| } |
| .content a { |
| display: block; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| width: 100%; |
| } |
| .comments, |
| .reviewers, |
| .requirements { |
| white-space: nowrap; |
| } |
| .reviewers { |
| --account-max-length: 70px; |
| } |
| .spacer { |
| height: 0; |
| overflow: hidden; |
| } |
| .status { |
| align-items: center; |
| display: inline-flex; |
| } |
| .status .comma { |
| padding-right: var(--spacing-xs); |
| } |
| /* Used to hide the leading separator comma for statuses. */ |
| .status .comma:first-of-type { |
| display: none; |
| } |
| .size gr-tooltip-content { |
| margin: -0.4rem -0.6rem; |
| max-width: 2.5rem; |
| padding: var(--spacing-m) var(--spacing-l); |
| } |
| .size span { |
| border-radius: var(--border-radius); |
| color: var(--dashboard-size-text); |
| font-size: var(--font-size-small); |
| /* To set height and width of span, it has to be inline block */ |
| display: inline-block; |
| height: 20px; |
| width: 20px; |
| text-align: center; |
| vertical-align: top; |
| } |
| .size span.size-xs { |
| background-color: var(--dashboard-size-xs); |
| color: var(--dashboard-size-xs-text); |
| } |
| .size span.size-s { |
| background-color: var(--dashboard-size-s); |
| } |
| .size span.size-m { |
| background-color: var(--dashboard-size-m); |
| } |
| .size span.size-l { |
| background-color: var(--dashboard-size-l); |
| } |
| .size span.size-xl { |
| background-color: var(--dashboard-size-xl); |
| color: var(--dashboard-size-xl-text); |
| } |
| a { |
| color: inherit; |
| cursor: pointer; |
| text-decoration: none; |
| } |
| a:hover { |
| text-decoration: underline; |
| } |
| .subject:hover .content { |
| text-decoration: underline; |
| } |
| .comma, |
| .placeholder { |
| color: var(--deemphasized-text-color); |
| } |
| .cell.selection input { |
| vertical-align: middle; |
| } |
| .selectionLabel { |
| padding: 10px; |
| margin: -10px; |
| } |
| .cell.label { |
| font-weight: var(--font-weight-normal); |
| } |
| .cell.label gr-icon { |
| vertical-align: top; |
| } |
| /* Requirement child needs whole area */ |
| .cell.requirement { |
| padding: 0; |
| margin: 0; |
| } |
| @media only screen and (max-width: 50em) { |
| :host { |
| display: flex; |
| } |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| const changeUrl = this.computeChangeURL(); |
| return html` |
| <td aria-hidden="true" class="cell leftPadding"></td> |
| ${this.renderCellSelectionBox()} ${this.renderCellStar()} |
| ${this.renderCellNumber(changeUrl)} ${this.renderCellSubject(changeUrl)} |
| ${this.renderCellStatus()} ${this.renderCellOwner()} |
| ${this.renderCellReviewers()} ${this.renderCellComments()} |
| ${this.renderCellRepo()} ${this.renderCellBranch()} |
| ${this.renderCellUpdated()} ${this.renderCellSubmitted()} |
| ${this.renderCellWaiting()} ${this.renderCellSize()} |
| ${this.renderCellRequirements()} |
| ${this.labelNames?.map(labelNames => this.renderChangeLabels(labelNames))} |
| ${this.dynamicCellEndpoints?.map(pluginEndpointName => |
| this.renderChangePluginEndpoint(pluginEndpointName) |
| )} |
| `; |
| } |
| |
| private renderCellSelectionBox() { |
| return html` |
| <td class="cell selection"> |
| <!-- |
| The .checked property must be used rather than the attribute because |
| the attribute only controls the default checked state and does not |
| update the current checked state. |
| See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked |
| --> |
| <label class="selectionLabel"> |
| <input |
| type="checkbox" |
| .checked=${this.checked} |
| @click=${this.toggleCheckbox} |
| /> |
| </label> |
| </td> |
| `; |
| } |
| |
| private renderCellStar() { |
| if (!this.showStar) return; |
| |
| return html` |
| <td class="cell star"> |
| <gr-change-star .change=${this.change}></gr-change-star> |
| </td> |
| `; |
| } |
| |
| private renderCellNumber(changeUrl: string) { |
| if (!this.showNumber) return; |
| |
| return html` |
| <td class="cell number"> |
| <a href=${changeUrl}>${this.change?._number}</a> |
| </td> |
| `; |
| } |
| |
| private renderCellSubject(changeUrl: string) { |
| if ( |
| this.computeIsColumnHidden( |
| ColumnNames.SUBJECT, |
| this.visibleChangeTableColumns |
| ) |
| ) |
| return; |
| |
| return html` |
| <td class="cell subject"> |
| <a |
| title=${ifDefined(this.change?.subject)} |
| href=${changeUrl} |
| @click=${this.handleChangeClick} |
| > |
| <div class="container"> |
| <div |
| class=${classMap({ |
| content: true, |
| strikethrough: this.change?.status === ChangeStatus.ABANDONED, |
| })} |
| > |
| ${this.change?.subject} |
| </div> |
| <div class="spacer">${this.change?.subject}</div> |
| <span> </span> |
| </div> |
| </a> |
| </td> |
| `; |
| } |
| |
| private renderCellStatus() { |
| if ( |
| this.computeIsColumnHidden( |
| ColumnNames.STATUS, |
| this.visibleChangeTableColumns |
| ) |
| ) |
| return; |
| |
| return html` <td class="cell status">${this.renderChangeStatus()}</td> `; |
| } |
| |
| private renderChangeStatus() { |
| if (!this.changeStatuses().length) { |
| return html`<span class="placeholder">--</span>`; |
| } |
| |
| return this.changeStatuses().map( |
| status => html` |
| <div class="comma">,</div> |
| <gr-change-status flat .status=${status}></gr-change-status> |
| ` |
| ); |
| } |
| |
| private renderCellOwner() { |
| if ( |
| this.computeIsColumnHidden( |
| ColumnNames.OWNER, |
| this.visibleChangeTableColumns |
| ) |
| ) |
| return; |
| |
| return html` |
| <td class="cell owner"> |
| <gr-account-label |
| highlightAttention |
| clickable |
| .change=${this.change} |
| .account=${this.change?.owner} |
| ></gr-account-label> |
| </td> |
| `; |
| } |
| |
| private renderCellReviewers() { |
| if ( |
| this.computeIsColumnHidden( |
| ColumnNames.REVIEWERS, |
| this.visibleChangeTableColumns |
| ) |
| ) |
| return; |
| |
| return html` |
| <td class="cell reviewers"> |
| <div> |
| ${this.computePrimaryReviewers().map((reviewer, index) => |
| this.renderChangeReviewers(reviewer, index) |
| )} |
| ${this.computeAdditionalReviewersCount() |
| ? html`<span title=${this.computeAdditionalReviewersTitle()} |
| >+${this.computeAdditionalReviewersCount()}</span |
| >` |
| : ''} |
| </div> |
| </td> |
| `; |
| } |
| |
| private renderChangeReviewers(reviewer: AccountInfo, index: number) { |
| return html` |
| <gr-account-label |
| clickable |
| hideAvatar |
| firstName |
| highlightAttention |
| .change=${this.change} |
| .account=${reviewer} |
| ></gr-account-label |
| ><span ?hidden=${this.computeCommaHidden(index)} aria-hidden="true" |
| >, |
| </span> |
| `; |
| } |
| |
| private renderCellComments() { |
| if (this.computeIsColumnHidden('Comments', this.visibleChangeTableColumns)) |
| return; |
| |
| return html` |
| <td class="cell comments"> |
| ${this.change?.unresolved_comment_count |
| ? html`<gr-icon icon="mode_comment" filled></gr-icon>` |
| : ''} |
| <span |
| >${this.computeComments(this.change?.unresolved_comment_count)}</span |
| > |
| </td> |
| `; |
| } |
| |
| private renderCellRepo() { |
| if ( |
| this.computeIsColumnHidden( |
| ColumnNames.REPO, |
| this.visibleChangeTableColumns |
| ) |
| ) { |
| return; |
| } |
| |
| const repo = this.change?.project ?? ''; |
| return html` |
| <td class="cell repo"> |
| <a class="fullRepo" href=${this.computeRepoUrl()}> ${repo} </a> |
| <a class="truncatedRepo" href=${this.computeRepoUrl()} title=${repo}> |
| ${truncatePath(repo, 2)} |
| </a> |
| </td> |
| `; |
| } |
| |
| private renderCellBranch() { |
| if ( |
| this.computeIsColumnHidden( |
| ColumnNames.BRANCH, |
| this.visibleChangeTableColumns |
| ) |
| ) |
| return; |
| |
| return html` |
| <td class="cell branch"> |
| <a href=${this.computeRepoBranchURL()}> ${this.change?.branch} </a> |
| ${this.renderChangeBranch()} |
| </td> |
| `; |
| } |
| |
| private renderChangeBranch() { |
| if (!this.change?.topic) return; |
| |
| return html` |
| (<a href=${this.computeTopicURL()} |
| ><!-- |
| --><gr-limited-text .limit=${50} .text=${this.change.topic}> |
| </gr-limited-text |
| ><!-- |
| --></a |
| >) |
| `; |
| } |
| |
| private renderCellUpdated() { |
| if (this.computeIsColumnHidden('Updated', this.visibleChangeTableColumns)) |
| return; |
| |
| return html` |
| <td class="cell updated"> |
| <gr-date-formatter |
| withTooltip |
| .dateStr=${this.formatDate(this.change?.updated)} |
| ></gr-date-formatter> |
| </td> |
| `; |
| } |
| |
| private renderCellSubmitted() { |
| if (this.computeIsColumnHidden('Submitted', this.visibleChangeTableColumns)) |
| return; |
| |
| return html` |
| <td class="cell submitted"> |
| <gr-date-formatter |
| withTooltip |
| .dateStr=${this.formatDate(this.change?.submitted)} |
| ></gr-date-formatter> |
| </td> |
| `; |
| } |
| |
| private renderCellWaiting() { |
| if (this.computeIsColumnHidden(WAITING, this.visibleChangeTableColumns)) |
| return; |
| |
| return html` |
| <td class="cell waiting"> |
| <gr-date-formatter |
| withTooltip |
| forceRelative |
| relativeOptionNoAgo |
| .dateStr=${this.computeWaiting()} |
| ></gr-date-formatter> |
| </td> |
| `; |
| } |
| |
| private renderCellSize() { |
| if (this.computeIsColumnHidden('Size', this.visibleChangeTableColumns)) |
| return; |
| |
| return html` |
| <td class="cell size"> |
| <gr-tooltip-content has-tooltip title=${this.computeSizeTooltip()}> |
| ${this.renderChangeSize()} |
| </gr-tooltip-content> |
| </td> |
| `; |
| } |
| |
| private renderChangeSize() { |
| const changeSize = this.computeChangeSize(); |
| if (!changeSize) return html`<span class="placeholder">--</span>`; |
| |
| return html` |
| <span class="size-${changeSize.toLowerCase()}">${changeSize}</span> |
| `; |
| } |
| |
| private renderCellRequirements() { |
| if ( |
| this.computeIsColumnHidden( |
| ColumnNames.STATUS2, |
| this.visibleChangeTableColumns |
| ) |
| ) |
| return; |
| |
| return html` |
| <td class="cell requirements"> |
| <gr-change-list-column-requirements-summary .change=${this.change}> |
| </gr-change-list-column-requirements-summary> |
| </td> |
| `; |
| } |
| |
| private renderChangeLabels(labelName: string) { |
| return html` <td class="cell label requirement"> |
| <gr-change-list-column-requirement |
| .change=${this.change} |
| .labelName=${labelName} |
| > |
| </gr-change-list-column-requirement> |
| </td>`; |
| } |
| |
| private renderChangePluginEndpoint(pluginEndpointName: string) { |
| return html` |
| <td class="cell endpoint"> |
| <gr-endpoint-decorator name=${pluginEndpointName}> |
| <gr-endpoint-param name="change" .value=${this.change}> |
| </gr-endpoint-param> |
| </gr-endpoint-decorator> |
| </td> |
| `; |
| } |
| |
| private readonly onItemClick = (e: Event) => { |
| // Check the path to verify that the item row itself was directly clicked. |
| // This will allow users using screen readers like VoiceOver to select an |
| // item with j/k and go to the selected change with Ctrl+Option+Space, but |
| // not interfere with clicks on interactive elements within the |
| // gr-change-list-item such as account links, which will bubble through |
| // without triggering this extra navigation. |
| if (this.change && e.composedPath()[0] === this) { |
| this.getNavigation().setUrl(createChangeUrl({change: this.change})); |
| } |
| }; |
| |
| private changeStatuses() { |
| if (!this.change) return []; |
| return changeStatuses(this.change); |
| } |
| |
| private computeChangeURL() { |
| if (!this.change) return ''; |
| return createChangeUrl({change: this.change, usp: this.usp}); |
| } |
| |
| private computeRepoUrl() { |
| if (!this.change) return ''; |
| return createSearchUrl({project: this.change.project, statuses: ['open']}); |
| } |
| |
| private computeRepoBranchURL() { |
| if (!this.change) return ''; |
| return createSearchUrl({ |
| branch: this.change.branch, |
| project: this.change.project, |
| }); |
| } |
| |
| private computeTopicURL() { |
| if (!this.change?.topic) return ''; |
| return createSearchUrl({topic: this.change.topic}); |
| } |
| |
| private toggleCheckbox() { |
| assertIsDefined(this.change, 'change'); |
| this.checked = !this.checked; |
| this.getBulkActionsModel().toggleSelectedChangeNum(this.change._number); |
| } |
| |
| // private but used in test |
| computeSizeTooltip() { |
| if ( |
| !this.change || |
| this.change.insertions + this.change.deletions === 0 || |
| isNaN(this.change.insertions + this.change.deletions) |
| ) { |
| return 'Size unknown'; |
| } else { |
| return `added ${this.change.insertions}, removed ${this.change.deletions} lines`; |
| } |
| } |
| |
| private hasAttention(account: AccountInfo) { |
| if (!this.change || !this.change.attention_set || !account._account_id) { |
| return false; |
| } |
| return hasOwnProperty(this.change.attention_set, account._account_id); |
| } |
| |
| /** |
| * Computes the array of all reviewers with sorting the reviewers in the |
| * attention set before others, and the current user first. |
| * |
| * private but used in test |
| */ |
| computeReviewers() { |
| if (!this.change?.reviewers || !this.change?.reviewers.REVIEWER) return []; |
| const reviewers = [...this.change.reviewers.REVIEWER].filter( |
| r => |
| (!this.change?.owner || |
| this.change?.owner._account_id !== r._account_id) && |
| !isServiceUser(r) |
| ); |
| reviewers.sort((r1, r2) => { |
| if (this.account) { |
| if (isSelf(r1, this.account)) return -1; |
| if (isSelf(r2, this.account)) return 1; |
| } |
| if (this.hasAttention(r1) && !this.hasAttention(r2)) return -1; |
| if (this.hasAttention(r2) && !this.hasAttention(r1)) return 1; |
| return (r1.name || '').localeCompare(r2.name || ''); |
| }); |
| return reviewers; |
| } |
| |
| private computePrimaryReviewers() { |
| return this.computeReviewers().slice(0, PRIMARY_REVIEWERS_COUNT); |
| } |
| |
| private computeAdditionalReviewers() { |
| return this.computeReviewers().slice(PRIMARY_REVIEWERS_COUNT); |
| } |
| |
| private computeAdditionalReviewersCount() { |
| return this.computeAdditionalReviewers().length; |
| } |
| |
| private computeAdditionalReviewersTitle() { |
| if (!this.change || !this.config) return ''; |
| return this.computeAdditionalReviewers() |
| .map(user => getDisplayName(this.config, user, true)) |
| .join(', '); |
| } |
| |
| private computeComments(unresolved_comment_count?: number) { |
| if (!unresolved_comment_count || unresolved_comment_count < 1) return ''; |
| return `${unresolved_comment_count} unresolved`; |
| } |
| |
| /** |
| * TShirt sizing is based on the following paper: |
| * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf |
| * |
| * private but used in test |
| */ |
| computeChangeSize() { |
| if (!this.change) return null; |
| const delta = this.change.insertions + this.change.deletions; |
| if (isNaN(delta) || delta === 0) { |
| return null; // Unknown |
| } |
| if (delta < ChangeSize.XS) { |
| return 'XS'; |
| } else if (delta < ChangeSize.SMALL) { |
| return 'S'; |
| } else if (delta < ChangeSize.MEDIUM) { |
| return 'M'; |
| } else if (delta < ChangeSize.LARGE) { |
| return 'L'; |
| } else { |
| return 'XL'; |
| } |
| } |
| |
| private computeWaiting(): Timestamp | undefined { |
| if (!this.account?._account_id || !this.change?.attention_set) |
| return undefined; |
| return this.change?.attention_set[this.account._account_id]?.last_update; |
| } |
| |
| private computeIsColumnHidden( |
| columnToCheck?: string, |
| columnsToDisplay?: string[] |
| ) { |
| if (!columnsToDisplay || !columnToCheck) { |
| return false; |
| } |
| return !columnsToDisplay.includes(columnToCheck); |
| } |
| |
| private formatDate(date: Timestamp | undefined): string | undefined { |
| if (!date) return undefined; |
| return date.toString(); |
| } |
| |
| private handleChangeClick() { |
| // Don't prevent the default and neither stop bubbling. We just want to |
| // report the click, but then let the browser handle the click on the link. |
| |
| const selfId = (this.account && this.account._account_id) || -1; |
| const ownerId = |
| (this.change && this.change.owner && this.change.owner._account_id) || -1; |
| |
| this.reporting.reportInteraction('change-row-clicked', { |
| section: this.sectionName, |
| isOwner: selfId === ownerId, |
| }); |
| } |
| |
| private computeCommaHidden(index: number) { |
| const additionalCount = this.computeAdditionalReviewersCount(); |
| const primaryCount = this.computePrimaryReviewers().length; |
| const isLast = index === primaryCount - 1; |
| return isLast && additionalCount === 0; |
| } |
| } |