| /** |
| * @license |
| * Copyright 2022 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../gr-avatar/gr-avatar'; |
| import '../gr-button/gr-button'; |
| 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 { |
| accountKey, |
| computeVoteableText, |
| isAccountEmailOnly, |
| isSelf, |
| } from '../../../utils/account-util'; |
| import {customElement, property, state} from 'lit/decorators.js'; |
| import { |
| AccountInfo, |
| ChangeInfo, |
| ServerInfo, |
| ReviewInput, |
| } from '../../../types/common'; |
| import { |
| canHaveAttention, |
| getAddedByReason, |
| getLastUpdate, |
| getReason, |
| getRemovedByReason, |
| hasAttention, |
| } from '../../../utils/attention-set-util'; |
| import {ReviewerState} from '../../../constants/constants'; |
| import {CURRENT} from '../../../utils/patch-set-util'; |
| import {isInvolved, isRemovableReviewer} from '../../../utils/change-util'; |
| import {assertIsDefined} from '../../../utils/common-util'; |
| import {fontStyles} from '../../../styles/gr-font-styles'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {css, html, LitElement, nothing} from 'lit'; |
| import {ifDefined} from 'lit/directives/if-defined.js'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {resolve} from '../../../models/dependency'; |
| import {configModelToken} from '../../../models/config/config-model'; |
| import {createSearchUrl} from '../../../models/views/search'; |
| import { |
| DashboardType, |
| createDashboardUrl, |
| } from '../../../models/views/dashboard'; |
| import {fire, fireReload} from '../../../utils/event-util'; |
| import {userModelToken} from '../../../models/user/user-model'; |
| import {getDocUrl} from '../../../utils/url-util'; |
| |
| @customElement('gr-hovercard-account-contents') |
| export class GrHovercardAccountContents extends LitElement { |
| @property({type: Object}) |
| account!: AccountInfo; |
| |
| @state() |
| selfAccount?: 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 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; |
| |
| @state() |
| serverConfig?: ServerInfo; |
| |
| @state() private docsBaseUrl = ''; |
| |
| private readonly restApiService = getAppContext().restApiService; |
| |
| private readonly reporting = getAppContext().reportingService; |
| |
| private readonly getUserModel = resolve(this, userModelToken); |
| |
| private readonly getConfigModel = resolve(this, configModelToken); |
| |
| constructor() { |
| super(); |
| subscribe( |
| this, |
| () => this.getUserModel().account$, |
| x => (this.selfAccount = x) |
| ); |
| subscribe( |
| this, |
| () => this.getConfigModel().serverConfig$, |
| config => { |
| this.serverConfig = config; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getConfigModel().docsBaseUrl$, |
| docsBaseUrl => (this.docsBaseUrl = docsBaseUrl) |
| ); |
| } |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| fontStyles, |
| css` |
| .top, |
| .attention, |
| .status, |
| .displayName, |
| .voteable { |
| padding: var(--spacing-s) var(--spacing-l); |
| } |
| .links { |
| padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl); |
| } |
| .top { |
| display: flex; |
| padding-top: var(--spacing-xl); |
| min-width: 300px; |
| } |
| gr-avatar { |
| height: 48px; |
| width: 48px; |
| margin-right: var(--spacing-l); |
| } |
| .title, |
| .email { |
| color: var(--deemphasized-text-color); |
| } |
| .action { |
| border-top: 1px solid var(--border-color); |
| padding: var(--spacing-s) var(--spacing-l); |
| --gr-button-padding: var(--spacing-s) var(--spacing-m); |
| } |
| .attention { |
| background-color: var(--emphasis-color); |
| } |
| .attention a { |
| text-decoration: none; |
| } |
| .status gr-icon { |
| font-size: 14px; |
| position: relative; |
| top: 2px; |
| } |
| gr-icon.attentionIcon { |
| transform: scaleX(0.8); |
| } |
| gr-icon.linkIcon { |
| font-size: var(--line-height-normal, 20px); |
| color: var(--deemphasized-text-color); |
| padding-right: 12px; |
| } |
| .links a { |
| color: var(--link-color); |
| padding: 0px 4px; |
| } |
| .reason { |
| padding-top: var(--spacing-s); |
| } |
| .status .value { |
| white-space: pre-wrap; |
| } |
| /* Make sure that users cannot break the layout with super long |
| "About Me" texts. */ |
| div.status { |
| max-height: 8em; |
| overflow-y: auto; |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| return html` |
| <div class="top"> |
| <div class="avatar"> |
| <gr-avatar .account=${this.account} .imageSize=${56}></gr-avatar> |
| </div> |
| <div class="account"> |
| <h3 class="name heading-3">${this.account.name}</h3> |
| <div class="email">${this.account.email}</div> |
| </div> |
| </div> |
| ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()} |
| ${this.renderDisplayName()} ${this.renderLinks()} |
| ${this.renderChangeRelatedInfoAndActions()} |
| `; |
| } |
| |
| private renderChangeRelatedInfoAndActions() { |
| if (this.change === undefined) { |
| return nothing; |
| } |
| const voteableText = computeVoteableText(this.change, this.account); |
| return html` |
| ${voteableText |
| ? html` |
| <div class="voteable"> |
| <span class="title">Voteable:</span> |
| <span class="value">${voteableText}</span> |
| </div> |
| ` |
| : ''} |
| ${this.renderNeedsAttention()} ${this.renderAddToAttention()} |
| ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()} |
| `; |
| } |
| |
| private renderReviewerOrCcActions() { |
| // `selfAccount` is required so that logged out users can't perform actions. |
| if (!this.selfAccount || !isRemovableReviewer(this.change, this.account)) |
| return nothing; |
| return html` |
| <div class="action"> |
| <gr-button |
| class="removeReviewerOrCC" |
| link |
| no-uppercase |
| @click=${this.handleRemoveReviewerOrCC} |
| > |
| Remove ${this.computeReviewerOrCCText()} |
| </gr-button> |
| </div> |
| <div class="action"> |
| <gr-button |
| class="changeReviewerOrCC" |
| link |
| no-uppercase |
| @click=${this.handleChangeReviewerOrCCStatus} |
| > |
| ${this.computeChangeReviewerOrCCText()} |
| </gr-button> |
| </div> |
| `; |
| } |
| |
| private renderAccountStatusPlugins() { |
| return html` |
| <gr-endpoint-decorator name="hovercard-status"> |
| <gr-endpoint-param |
| name="account" |
| .value=${this.account} |
| ></gr-endpoint-param> |
| </gr-endpoint-decorator> |
| `; |
| } |
| |
| private renderLinks() { |
| if (!this.account || isAccountEmailOnly(this.account)) return nothing; |
| return html` <div class="links"> |
| <gr-icon icon="link" class="linkIcon"></gr-icon> |
| <a |
| href=${ifDefined(this.computeOwnerChangesLink())} |
| @click=${() => { |
| fire(this, 'link-clicked', {}); |
| }} |
| @enter=${() => { |
| fire(this, 'link-clicked', {}); |
| }} |
| >Changes</a |
| > |
| · |
| <a |
| href=${ifDefined(this.computeOwnerDashboardLink())} |
| @click=${() => { |
| fire(this, 'link-clicked', {}); |
| }} |
| @enter=${() => { |
| fire(this, 'link-clicked', {}); |
| }} |
| >Dashboard</a |
| > |
| <gr-endpoint-decorator name="hovercard-links"> |
| <gr-endpoint-param |
| name="account" |
| .value=${this.account} |
| ></gr-endpoint-param> |
| </gr-endpoint-decorator> |
| </div>`; |
| } |
| |
| private renderAccountStatus() { |
| if (!this.account.status) return nothing; |
| return html` |
| <div class="status"> |
| <span class="title">About me:</span> |
| <span class="value">${this.account.status.trim()}</span> |
| </div> |
| `; |
| } |
| |
| private renderDisplayName() { |
| if (!this.account.display_name) return nothing; |
| return html` |
| <div class="displayName"> |
| <span class="title">Display name:</span> |
| <span class="value">${this.account.display_name.trim()}</span> |
| </div> |
| `; |
| } |
| |
| private renderNeedsAttention() { |
| if (!(this.isAttentionEnabled && this.hasUserAttention)) return nothing; |
| const lastUpdate = getLastUpdate(this.account, this.change); |
| return html` |
| <div class="attention"> |
| <div> |
| <gr-icon |
| icon="label_important" |
| filled |
| small |
| class="attentionIcon" |
| ></gr-icon> |
| <span> ${this.computePronoun()} turn to take action. </span> |
| <a |
| href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')} |
| target="_blank" |
| rel="noopener noreferrer" |
| > |
| <gr-icon icon="help" title="read documentation"></gr-icon> |
| </a> |
| </div> |
| <div class="reason"> |
| <span class="title">Reason:</span> |
| <span class="value"> |
| ${getReason(this.serverConfig, this.account, this.change)} |
| </span> |
| ${lastUpdate |
| ? html` ( |
| <gr-date-formatter |
| withTooltip |
| .dateStr=${lastUpdate} |
| ></gr-date-formatter> |
| )` |
| : ''} |
| </div> |
| </div> |
| `; |
| } |
| |
| private renderAddToAttention() { |
| if (!this.computeShowActionAddToAttentionSet()) return nothing; |
| return html` |
| <div class="action"> |
| <gr-button |
| class="addToAttentionSet" |
| link |
| no-uppercase |
| @click=${this.handleClickAddToAttentionSet} |
| > |
| Add to attention set |
| </gr-button> |
| </div> |
| `; |
| } |
| |
| private renderRemoveFromAttention() { |
| if (!this.computeShowActionRemoveFromAttentionSet()) return nothing; |
| return html` |
| <div class="action"> |
| <gr-button |
| class="removeFromAttentionSet" |
| link |
| no-uppercase |
| @click=${this.handleClickRemoveFromAttentionSet} |
| > |
| Remove from attention set |
| </gr-button> |
| </div> |
| `; |
| } |
| |
| // private but used by tests |
| computePronoun() { |
| if (!this.account || !this.selfAccount) return ''; |
| return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their'; |
| } |
| |
| computeOwnerChangesLink() { |
| if (!this.account) return undefined; |
| return createSearchUrl({ |
| owner: |
| this.account.email || |
| this.account.username || |
| this.account.name || |
| `${this.account._account_id}`, |
| }); |
| } |
| |
| computeOwnerDashboardLink() { |
| if (!this.account) return undefined; |
| if (this.account._account_id) |
| return createDashboardUrl({ |
| type: DashboardType.USER, |
| user: `${this.account._account_id}`, |
| }); |
| if (this.account.email) |
| return createDashboardUrl({ |
| type: DashboardType.USER, |
| user: this.account.email, |
| }); |
| return undefined; |
| } |
| |
| get isAttentionEnabled() { |
| return ( |
| !!this.highlightAttention && |
| !!this.change && |
| canHaveAttention(this.account) |
| ); |
| } |
| |
| get hasUserAttention() { |
| return hasAttention(this.account, this.change); |
| } |
| |
| private getReviewerState(change: ChangeInfo) { |
| if ( |
| change.reviewers[ReviewerState.REVIEWER]?.some( |
| (reviewer: AccountInfo) => |
| reviewer._account_id === this.account._account_id |
| ) |
| ) { |
| return ReviewerState.REVIEWER; |
| } |
| return ReviewerState.CC; |
| } |
| |
| private computeReviewerOrCCText() { |
| if (!this.change || !this.account) return ''; |
| return this.getReviewerState(this.change) === ReviewerState.REVIEWER |
| ? 'Reviewer' |
| : 'CC'; |
| } |
| |
| private computeChangeReviewerOrCCText() { |
| if (!this.change || !this.account) return ''; |
| return this.getReviewerState(this.change) === ReviewerState.REVIEWER |
| ? 'Move Reviewer to CC' |
| : 'Move CC to Reviewer'; |
| } |
| |
| private handleChangeReviewerOrCCStatus() { |
| assertIsDefined(this.change, 'change'); |
| // accountKey() throws an error if _account_id & email is not found, which |
| // we want to check before showing reloading toast |
| const _accountKey = accountKey(this.account); |
| fire(this, 'show-alert', { |
| message: 'Reloading page...', |
| }); |
| const reviewInput: Partial<ReviewInput> = {}; |
| reviewInput.reviewers = [ |
| { |
| reviewer: _accountKey, |
| state: |
| this.getReviewerState(this.change) === ReviewerState.CC |
| ? ReviewerState.REVIEWER |
| : ReviewerState.CC, |
| }, |
| ]; |
| |
| this.restApiService |
| .saveChangeReview(this.change._number, CURRENT, reviewInput) |
| .then(response => { |
| if (!response) { |
| throw new Error( |
| 'something went wrong when toggling' + |
| this.getReviewerState(this.change!) |
| ); |
| } |
| fireReload(this); |
| }); |
| } |
| |
| private handleRemoveReviewerOrCC() { |
| if (!this.change || !(this.account?._account_id || this.account?.email)) |
| throw new Error('Missing change or account.'); |
| fire(this, 'show-alert', { |
| message: 'Reloading page...', |
| }); |
| this.restApiService |
| .removeChangeReviewer( |
| this.change._number, |
| (this.account?._account_id || this.account?.email)! |
| ) |
| .then((response: Response | undefined) => { |
| if (!response || !response.ok) { |
| throw new Error('something went wrong when removing user'); |
| } |
| fireReload(this); |
| return response; |
| }); |
| } |
| |
| private computeShowActionAddToAttentionSet() { |
| const involvedOrSelf = |
| isInvolved(this.change, this.selfAccount) || |
| isSelf(this.account, this.selfAccount); |
| return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention; |
| } |
| |
| private computeShowActionRemoveFromAttentionSet() { |
| const involvedOrSelf = |
| isInvolved(this.change, this.selfAccount) || |
| isSelf(this.account, this.selfAccount); |
| return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention; |
| } |
| |
| private handleClickAddToAttentionSet() { |
| if (!this.change || !this.account._account_id) return; |
| fire(this, 'show-alert', { |
| message: 'Reloading page...', |
| 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 = getAddedByReason(this.selfAccount, this.serverConfig); |
| |
| if (!this.change.attention_set) this.change.attention_set = {}; |
| this.change.attention_set[this.account._account_id] = { |
| account: this.account, |
| reason, |
| reason_account: this.selfAccount, |
| }; |
| fire(this, 'attention-set-updated', {}); |
| |
| this.reporting.reportInteraction( |
| 'attention-hovercard-add', |
| this.reportingDetails() |
| ); |
| this.restApiService |
| .addToAttentionSet(this.change._number, this.account._account_id, reason) |
| .then(() => { |
| fire(this, 'hide-alert', {}); |
| }); |
| fire(this, 'action-taken', {}); |
| } |
| |
| private handleClickRemoveFromAttentionSet() { |
| if (!this.change || !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 = getRemovedByReason(this.selfAccount, this.serverConfig); |
| if (this.change.attention_set) |
| delete this.change.attention_set[this.account._account_id]; |
| fire(this, 'attention-set-updated', {}); |
| |
| this.reporting.reportInteraction( |
| 'attention-hovercard-remove', |
| this.reportingDetails() |
| ); |
| this.restApiService |
| .removeFromAttentionSet( |
| this.change._number, |
| this.account._account_id, |
| reason |
| ) |
| .then(() => { |
| fire(this, 'hide-alert', {}); |
| }); |
| fire(this, 'action-taken', {}); |
| } |
| |
| private reportingDetails() { |
| const targetId = this.account._account_id; |
| const ownerId = |
| (this.change && this.change.owner && this.change.owner._account_id) || -1; |
| const selfId = (this.selfAccount && 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, |
| }; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-hovercard-account-contents': GrHovercardAccountContents; |
| } |
| interface HTMLElementEventMap { |
| 'action-taken': CustomEvent<{}>; |
| 'attention-set-updated': CustomEvent<{}>; |
| 'link-clicked': CustomEvent<{}>; |
| } |
| } |