blob: f746c29a2d7f7fc7cdfb5e38379653ad16750f39 [file] [log] [blame]
/**
* @license
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@polymer/iron-icon/iron-icon';
import '../gr-avatar/gr-avatar';
import '../gr-hovercard-account/gr-hovercard-account';
import {appContext} from '../../../services/app-context';
import {getDisplayName} from '../../../utils/display-name-util';
import {isSelf, isServiceUser} from '../../../utils/account-util';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
import {hasOwnProperty} from '../../../utils/common-util';
import {fireEvent} from '../../../utils/event-util';
import {isInvolved} from '../../../utils/change-util';
import {ShowAlertEventDetail} from '../../../types/events';
import {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {classMap} from 'lit/directives/class-map';
import {modifierPressed} from '../../../utils/dom-util';
import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
@customElement('gr-account-label')
export class GrAccountLabel extends LitElement {
@property({type: Object})
account?: AccountInfo;
@property({type: Object})
_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;
@property({type: String})
voteableText?: string;
/**
* 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,
})
cancelLeftPadding = false;
@property({type: Boolean})
hideStatus = false;
@state()
_config?: ServerInfo;
@property({type: Boolean, reflect: true})
selectionChipStyle = false;
@property({
type: Boolean,
reflect: true,
})
selected = false;
@property({type: Boolean, reflect: true})
deselected = false;
reporting: ReportingService;
private readonly restApiService = appContext.restApiService;
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: 0 var(--account-label-padding-horizontal, 0);
}
/* If the first element is the avatar, then we cancel the left padding,
so we can fit nicely into the gr-account-chip rounding. The obvious
alternative of 'chip has padding' and 'avatar gets negative margin'
does not work, because we need 'overflow:hidden' on the label. */
:host([cancelLeftPadding]) {
padding-left: 0;
}
: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]) iron-icon.attention {
color: var(--chip-selected-text-color);
}
gr-avatar {
height: calc(var(--line-height-normal) - 2px);
width: calc(var(--line-height-normal) - 2px);
vertical-align: top;
position: relative;
top: 1px;
}
#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;
vertical-align: top;
}
iron-icon.attention {
color: var(--deemphasized-text-color);
width: 12px;
height: 12px;
vertical-align: top;
}
iron-icon.status {
color: var(--deemphasized-text-color);
width: 14px;
height: 14px;
vertical-align: top;
position: relative;
top: 2px;
}
.name {
display: inline-block;
text-decoration: inherit;
vertical-align: top;
overflow: hidden;
text-overflow: ellipsis;
max-width: var(--account-max-length, 180px);
}
.hasAttention .name {
font-weight: var(--font-weight-bold);
}
`,
];
}
override render() {
const {account, change, highlightAttention, forceAttention, _config} = this;
if (!account) return;
const hasAttention =
forceAttention ||
this._hasUnforcedAttention(highlightAttention, account, change);
this.deselected = !this.selected;
const hasAvatars = !!_config?.plugin?.has_avatars;
this.cancelLeftPadding = !this.hideAvatar && !hasAttention && hasAvatars;
return html`<span>
${!this.hideHovercard
? html`<gr-hovercard-account
for="hovercardTarget"
.account="${account}"
.change="${change}"
?highlight-attention=${highlightAttention}
.voteableText=${this.voteableText}
></gr-hovercard-account>`
: ''}
${hasAttention
? html`<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
)}
?has-tooltip=${this._computeAttentionButtonEnabled(
highlightAttention,
account,
change,
false,
this._selfAccount
)}
title="${this._computeAttentionIconTitle(
highlightAttention,
account,
change,
forceAttention,
this.selected,
this._selfAccount
)}"
><iron-icon
class="attention"
icon="gr-icons:attention"
></iron-icon>
</gr-button>`
: ''}
</span>
<span
id="hovercardTarget"
tabindex="0"
@keydown="${(e: KeyboardEvent) => this.handleKeyDown(e)}"
class="${classMap({
hasAttention: !!hasAttention,
})}"
>
${!this.hideAvatar
? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
: ''}
<span class="text" part="gr-account-label-text">
<span class="name"
>${this._computeName(account, this.firstName, this._config)}</span
>
${!this.hideStatus && account.status
? html`<iron-icon
class="status"
icon="gr-icons:calendar"
></iron-icon>`
: ''}
</span>
</span>`;
}
constructor() {
super();
this.reporting = appContext.reportingService;
this.restApiService.getConfig().then(config => {
this._config = config;
});
this.restApiService.getAccount().then(account => {
this._selfAccount = account;
});
this.addEventListener('attention-set-updated', () => {
// For re-evaluation of everything that depends on 'change'.
if (this.change) this.change = {...this.change};
});
}
handleKeyDown(e: KeyboardEvent) {
if (modifierPressed(e)) return;
// Only react to `return` and `space`.
if (e.keyCode !== 13 && e.keyCode !== 32) return;
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(new Event('click'));
}
_isAttentionSetEnabled(
highlight: boolean,
account: AccountInfo,
change?: ChangeInfo
) {
return highlight && !!change && !!account && !isServiceUser(account);
}
_hasUnforcedAttention(
highlight: boolean,
account: AccountInfo,
change?: ChangeInfo
) {
return (
this._isAttentionSetEnabled(highlight, account, change) &&
change &&
change.attention_set &&
!!account._account_id &&
hasOwnProperty(change.attention_set, account._account_id)
);
}
_computeName(
account?: AccountInfo,
firstName?: boolean,
config?: ServerInfo
) {
return getDisplayName(config, account, firstName);
}
_handleRemoveAttentionClick(e: MouseEvent) {
if (!this.account || !this.change) return;
if (this.selected) return;
e.preventDefault();
e.stopPropagation();
if (!this.account._account_id) return;
this.dispatchEvent(
new CustomEvent<ShowAlertEventDetail>('show-alert', {
detail: {
message: 'Saving attention set update ...',
dismissOnNavigation: true,
},
composed: true,
bubbles: 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(() => {
fireEvent(this, 'hide-alert');
});
}
_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,
};
}
_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))
);
}
_computeAttentionIconTitle(
highlight: boolean,
account: AccountInfo,
change: ChangeInfo | undefined,
force: boolean,
selected: boolean,
selfAccount?: AccountInfo
) {
const enabled = this._computeAttentionButtonEnabled(
highlight,
account,
change,
selected,
selfAccount
);
return enabled
? 'Click to remove the user from the attention set'
: force
? 'Disabled. Use "Modify" to make changes.'
: 'Disabled. Only involved users can change.';
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-account-label': GrAccountLabel;
}
}