| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../gr-avatar/gr-avatar'; |
| import '../gr-hovercard-account/gr-hovercard-account'; |
| import '../gr-icon/gr-icon'; |
| import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; |
| import '../../plugins/gr-endpoint-param/gr-endpoint-param'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {getDisplayName} from '../../../utils/display-name-util'; |
| import { |
| isDetailedAccount, |
| isSelf, |
| isServiceUser, |
| } from '../../../utils/account-util'; |
| import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common'; |
| import {hasOwnProperty} from '../../../utils/common-util'; |
| import {fire} from '../../../utils/event-util'; |
| import {isInvolved} from '../../../utils/change-util'; |
| import {LitElement, css, html, TemplateResult} from 'lit'; |
| import {customElement, property, state} from 'lit/decorators.js'; |
| import {classMap} from 'lit/directives/class-map.js'; |
| import {getRemovedByIconClickReason} from '../../../utils/attention-set-util'; |
| import {ifDefined} from 'lit/directives/if-defined.js'; |
| import {createSearchUrl} from '../../../models/views/search'; |
| import {accountsModelToken} from '../../../models/accounts/accounts-model'; |
| import {resolve} from '../../../models/dependency'; |
| import {configModelToken} from '../../../models/config/config-model'; |
| import {userModelToken} from '../../../models/user/user-model'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| |
| @customElement('gr-account-label') |
| export class GrAccountLabel extends LitElement { |
| @property({type: Object}) |
| account?: AccountInfo; |
| |
| /** |
| * Optional ChangeInfo object, typically comes from the change page or |
| * from a row in a list of search results. This is needed for some change |
| * related features like adding the user as a reviewer. |
| */ |
| @property({type: Object}) |
| change?: ChangeInfo; |
| |
| /** |
| * Should this user be considered to be in the attention set, regardless |
| * of the current state of the change object? |
| */ |
| @property({type: Boolean}) |
| forceAttention = false; |
| |
| /** |
| * Only show the first name in the account label. |
| */ |
| @property({type: Boolean}) |
| firstName = false; |
| |
| /** |
| * Should attention set related features be shown in the component? Note |
| * that the information whether the user is in the attention set or not is |
| * part of the ChangeInfo object in the change property. |
| */ |
| @property({type: Boolean}) |
| highlightAttention = false; |
| |
| @property({type: Boolean}) |
| hideHovercard = false; |
| |
| @property({type: Boolean}) |
| hideAvatar = false; |
| |
| @property({type: Boolean, reflect: true}) |
| selectionChipStyle = false; |
| |
| @property({type: Boolean, reflect: true}) |
| noStatusIcons = false; |
| |
| @property({ |
| type: Boolean, |
| reflect: true, |
| }) |
| selected = false; |
| |
| @property({type: Boolean, reflect: true}) |
| deselected = false; |
| |
| @property({type: Boolean, reflect: true}) |
| clickable = false; |
| |
| @property({type: Boolean, reflect: true}) |
| attentionIconShown = false; |
| |
| @property({type: Boolean, reflect: true}) |
| avatarShown = false; |
| |
| // Private but used in tests. |
| @state() |
| selfAccount?: AccountInfo; |
| |
| // Private but used in tests. |
| @state() |
| config?: ServerInfo; |
| |
| readonly reporting = getAppContext().reportingService; |
| |
| private readonly restApiService = getAppContext().restApiService; |
| |
| private readonly getAccountsModel = resolve(this, accountsModelToken); |
| |
| private readonly getConfigModel = resolve(this, configModelToken); |
| |
| private readonly getUserModel = resolve(this, userModelToken); |
| |
| static override get styles() { |
| return [ |
| css` |
| :host { |
| display: inline-block; |
| vertical-align: top; |
| position: relative; |
| border-radius: var(--label-border-radius); |
| box-sizing: border-box; |
| white-space: nowrap; |
| padding-left: var(--account-label-padding-left, 0); |
| } |
| :host([avatarShown]:not([attentionIconShown])) { |
| padding-left: var(--account-label-circle-padding-left, 0); |
| } |
| :host([attentionIconShown]) { |
| padding-left: var(--account-label-padding-left, 0); |
| } |
| .rightSidePadding { |
| padding-right: var(--account-label-padding-right, 0); |
| /* The existence of this element will also add 2(!) flexbox gaps */ |
| margin-left: -6px; |
| } |
| .container { |
| display: flex; |
| align-items: center; |
| gap: 3px; |
| } |
| :host::after { |
| content: var(--account-label-suffix); |
| } |
| :host([deselected][selectionChipStyle]) { |
| background-color: var(--background-color-primary); |
| border: 1px solid var(--comment-separator-color); |
| border-radius: 8px; |
| color: var(--deemphasized-text-color); |
| } |
| :host([selected][selectionChipStyle]) { |
| background-color: var(--chip-selected-background-color); |
| border: 1px solid var(--chip-selected-background-color); |
| border-radius: 8px; |
| color: var(--chip-selected-text-color); |
| } |
| :host([selected]) gr-icon.attention { |
| color: var(--chip-selected-text-color); |
| } |
| gr-avatar { |
| height: calc(var(--line-height-normal) - 2px); |
| width: calc(var(--line-height-normal) - 2px); |
| } |
| .accountStatusDecorator, |
| .hovercardTargetWrapper { |
| display: contents; |
| } |
| #attentionButton { |
| /* This negates the 4px horizontal padding, which we appreciate as a |
| larger click target, but which we don't want to consume space. :-) */ |
| margin: 0 -4px 0 -4px; |
| --gr-button-padding: 0 var(--spacing-xs); |
| vertical-align: top; |
| } |
| gr-icon.attention { |
| color: var(--deemphasized-text-color); |
| transform: scaleX(0.8); |
| } |
| .name { |
| display: inline-block; |
| vertical-align: top; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| max-width: var(--account-max-length, 180px); |
| } |
| .hasAttention .name { |
| font-weight: var(--font-weight-bold); |
| } |
| a.ownerLink { |
| text-decoration: none; |
| color: var(--primary-text-color); |
| display: flex; |
| align-items: center; |
| gap: 3px; |
| } |
| :host([clickable]) a.ownerLink:hover .name { |
| text-decoration: underline; |
| } |
| `, |
| ]; |
| } |
| |
| override updated() { |
| this.computeDetailedAccount(); |
| } |
| |
| private async computeDetailedAccount() { |
| if (!this.account) return; |
| // If this.account is already a detailed object, then there is no need to fill it. |
| if (isDetailedAccount(this.account)) return; |
| const account = await this.getAccountsModel().fillDetails(this.account); |
| if ( |
| account && |
| // If we were not able to get a detailed object, then there is no point in updating the |
| // account. |
| isDetailedAccount(account) && |
| account !== this.account && |
| (!this.account._account_id || |
| account._account_id === this.account._account_id) |
| ) { |
| // AccountInfo returned by fillDetails has the email property set |
| // to the primary email of the account. This poses a problem in |
| // cases where a secondary email is used as the committer or author |
| // email. Therefore, only fill in the *missing* properties. |
| this.account = {...account, ...this.account}; |
| } |
| } |
| |
| override render() { |
| const {account, change, highlightAttention, forceAttention, config} = this; |
| if (!account) return; |
| this.attentionIconShown = |
| forceAttention || |
| this.hasUnforcedAttention(highlightAttention, account, change); |
| this.deselected = !this.selected; |
| const hasAvatars = !!config?.plugin?.has_avatars; |
| this.avatarShown = !this.hideAvatar && hasAvatars; |
| |
| return html` |
| <div class="container"> |
| ${!this.hideHovercard |
| ? html`<gr-hovercard-account |
| for="hovercardTarget" |
| .account=${account} |
| .change=${change} |
| .highlightAttention=${highlightAttention} |
| ></gr-hovercard-account>` |
| : ''} |
| ${this.attentionIconShown |
| ? html` <gr-tooltip-content |
| ?has-tooltip=${this.computeAttentionButtonEnabled( |
| highlightAttention, |
| account, |
| change, |
| false, |
| this.selfAccount |
| )} |
| title=${this.computeAttentionIconTitle( |
| highlightAttention, |
| account, |
| change, |
| forceAttention, |
| this.selected, |
| this.selfAccount |
| )} |
| > |
| <gr-button |
| id="attentionButton" |
| link="" |
| aria-label="Remove user from attention set" |
| @click=${this.handleRemoveAttentionClick} |
| ?disabled=${!this.computeAttentionButtonEnabled( |
| highlightAttention, |
| account, |
| change, |
| this.selected, |
| this.selfAccount |
| )} |
| > |
| <div> |
| <gr-icon |
| icon="label_important" |
| filled |
| small |
| class="attention" |
| > |
| </gr-icon> |
| </div> |
| </gr-button> |
| </gr-tooltip-content>` |
| : ''} |
| ${this.maybeRenderLink(html` |
| <span |
| class=${classMap({ |
| hovercardTargetWrapper: true, |
| hasAttention: this.attentionIconShown, |
| })} |
| > |
| ${this.avatarShown |
| ? html`<gr-avatar .account=${account} imageSize="32"></gr-avatar>` |
| : ''} |
| <span |
| tabindex=${this.hideHovercard ? '-1' : '0'} |
| role=${ifDefined(this.hideHovercard ? undefined : 'button')} |
| id="hovercardTarget" |
| class="name" |
| part="gr-account-label-text" |
| > |
| ${this.computeName(account, this.firstName, this.config)} |
| </span> |
| ${this.renderAccountStatusPlugins()} |
| </span> |
| `)} |
| </div> |
| `; |
| } |
| |
| constructor() { |
| super(); |
| subscribe( |
| this, |
| () => this.getConfigModel().serverConfig$, |
| x => (this.config = x) |
| ); |
| subscribe( |
| this, |
| () => this.getUserModel().account$, |
| x => (this.selfAccount = x) |
| ); |
| this.addEventListener('attention-set-updated', () => { |
| // For re-evaluation of everything that depends on 'change'. |
| if (this.change) this.change = {...this.change}; |
| }); |
| } |
| |
| private maybeRenderLink(span: TemplateResult) { |
| if (!this.clickable || !this.account) return span; |
| const url = createSearchUrl({ |
| owner: |
| this.account.email || |
| this.account.username || |
| this.account.name || |
| `${this.account._account_id}`, |
| }); |
| if (!url) return span; |
| return html`<a class="ownerLink" href=${url} tabindex="-1">${span}</a>`; |
| } |
| |
| private renderAccountStatusPlugins() { |
| if (!this.account?._account_id || this.noStatusIcons) { |
| return; |
| } |
| return html` |
| <gr-endpoint-decorator |
| class="accountStatusDecorator" |
| name="account-status-icon" |
| > |
| <gr-endpoint-param |
| name="accountId" |
| .value=${this.account._account_id} |
| ></gr-endpoint-param> |
| <span class="rightSidePadding"></span> |
| </gr-endpoint-decorator> |
| `; |
| } |
| |
| private isAttentionSetEnabled( |
| highlight: boolean, |
| account: AccountInfo, |
| change?: ChangeInfo |
| ) { |
| return highlight && !!change && !!account && !isServiceUser(account); |
| } |
| |
| private hasUnforcedAttention( |
| highlight: boolean, |
| account: AccountInfo, |
| change?: ChangeInfo |
| ): boolean { |
| return !!( |
| this.isAttentionSetEnabled(highlight, account, change) && |
| change && |
| change.attention_set && |
| !!account._account_id && |
| hasOwnProperty(change.attention_set, account._account_id) |
| ); |
| } |
| |
| // Private but used in tests. |
| computeName(account?: AccountInfo, firstName?: boolean, config?: ServerInfo) { |
| return getDisplayName(config, account, firstName); |
| } |
| |
| private handleRemoveAttentionClick(e: MouseEvent) { |
| if (!this.account || !this.change) return; |
| if (this.selected) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (!this.account._account_id) return; |
| |
| fire(this, 'show-alert', { |
| message: 'Saving attention set update ...', |
| dismissOnNavigation: true, |
| }); |
| |
| // We are deliberately updating the UI before making the API call. It is a |
| // risk that we are taking to achieve a better UX for 99.9% of the cases. |
| const reason = getRemovedByIconClickReason(this.selfAccount, this.config); |
| if (this.change.attention_set) |
| delete this.change.attention_set[this.account._account_id]; |
| // For re-evaluation of everything that depends on 'change'. |
| this.change = {...this.change}; |
| |
| this.reporting.reportInteraction( |
| 'attention-icon-remove', |
| this.reportingDetails() |
| ); |
| this.restApiService |
| .removeFromAttentionSet( |
| this.change._number, |
| this.account._account_id, |
| reason |
| ) |
| .then(() => { |
| fire(this, 'hide-alert', {}); |
| }); |
| } |
| |
| private reportingDetails() { |
| if (!this.account) return; |
| const targetId = this.account._account_id; |
| const ownerId = |
| (this.change && this.change.owner && this.change.owner._account_id) || -1; |
| const selfId = this.selfAccount?._account_id || -1; |
| const reviewers = |
| this.change && this.change.reviewers && this.change.reviewers.REVIEWER |
| ? [...this.change.reviewers.REVIEWER] |
| : []; |
| const reviewerIds = reviewers |
| .map(r => r._account_id) |
| .filter(rId => rId !== ownerId); |
| return { |
| actionByOwner: selfId === ownerId, |
| actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId), |
| targetIsOwner: targetId === ownerId, |
| targetIsReviewer: reviewerIds.includes(targetId), |
| targetIsSelf: targetId === selfId, |
| }; |
| } |
| |
| private computeAttentionButtonEnabled( |
| highlight: boolean, |
| account: AccountInfo, |
| change: ChangeInfo | undefined, |
| selected: boolean, |
| selfAccount?: AccountInfo |
| ) { |
| if (selected) return true; |
| return ( |
| !!this.hasUnforcedAttention(highlight, account, change) && |
| (isInvolved(change, selfAccount) || isSelf(account, selfAccount)) |
| ); |
| } |
| |
| private computeAttentionIconTitle( |
| highlight: boolean, |
| account: AccountInfo, |
| change: ChangeInfo | undefined, |
| force: boolean, |
| selected: boolean, |
| selfAccount?: AccountInfo |
| ) { |
| const enabled = this.computeAttentionButtonEnabled( |
| highlight, |
| account, |
| change, |
| selected, |
| selfAccount |
| ); |
| const removeFromASTooltip = `Click to remove ${ |
| account._account_id === selfAccount?._account_id ? 'yourself' : 'the user' |
| } from the attention set`; |
| return enabled |
| ? removeFromASTooltip |
| : force |
| ? 'Disabled. Use "Modify" to make changes.' |
| : 'Disabled. Only involved users can change.'; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-account-label': GrAccountLabel; |
| } |
| } |