blob: e60e5d4f52a6afe431701cc3c28ffcc6fab58051 [file] [log] [blame]
/**
* @license
* Copyright (C) 2024 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 {Subscription} from 'rxjs';
import {css, html, LitElement, nothing, PropertyValues, CSSResult} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {
AccountInfo,
ApprovalInfo,
ChangeInfo,
ChangeStatus,
LabelInfo,
isDetailedLabelInfo,
EmailAddress,
} from '@gerritcodereview/typescript-api/rest-api';
import {
FilesOwners,
isOwner,
OwnersLabels,
OWNERS_SUBMIT_REQUIREMENT,
OwnersService,
} from './owners-service';
import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
import {
FileOwnership,
FileStatus,
ModelLoader,
OwnersModel,
PatchRange,
UserRole,
} from './owners-model';
import {query} from './utils';
import {GrAccountLabel} from './gerrit-model';
const STATUS_CODE = {
MISSING: 'missing',
};
const STATUS_ICON = {
[STATUS_CODE.MISSING]: 'schedule',
};
const FILE_STATUS = {
[FileStatus.NEEDS_APPROVAL]: STATUS_CODE.MISSING,
};
const DISPLAY_OWNERS_FOR_FILE_LIMIT = 5;
const LIMITED_FILES_OWNERS_TOOLTIP = `File owners limited to first ${DISPLAY_OWNERS_FOR_FILE_LIMIT} accounts.`;
// Lit mixin definition as described in https://lit.dev/docs/composition/mixins/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructor<T> = new (...args: any[]) => T;
interface CommonInterface extends LitElement {
change?: ChangeInfo;
patchRange?: PatchRange;
restApi?: RestPluginApi;
userRole?: UserRole;
allFilesApproved?: boolean;
filesOwners?: FilesOwners;
onModelUpdate(): void;
}
const CommonMixin = <T extends Constructor<LitElement>>(superClass: T) => {
class Mixin extends superClass {
@property({type: Object})
change?: ChangeInfo;
@property({type: Object})
patchRange?: PatchRange;
@property({type: Object})
restApi?: RestPluginApi;
@state()
userRole?: UserRole;
@state()
allFilesApproved?: boolean;
@state()
filesOwners?: FilesOwners;
private _model?: OwnersModel;
modelLoader?: ModelLoader;
private subscriptions: Array<Subscription> = [];
get model() {
return this._model;
}
set model(model: OwnersModel | undefined) {
if (this._model === model) return;
for (const s of this.subscriptions) {
s.unsubscribe();
}
this.subscriptions = [];
this._model = model;
if (!model) return;
this.subscriptions.push(
model.state$.subscribe(s => {
this.userRole = s.userRole;
})
);
this.subscriptions.push(
model.state$.subscribe(s => {
this.allFilesApproved = s.allFilesApproved;
})
);
this.subscriptions.push(
model.state$.subscribe(s => {
this.filesOwners = s.filesOwners;
})
);
this.onModelUpdate();
}
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has('change') || changedProperties.has('restApi')) {
if (!this.restApi || !this.change) {
this.model = undefined;
this.modelLoader = undefined;
return;
}
const service = OwnersService.getOwnersService(
this.restApi,
this.change
);
const model = OwnersModel.getModel(this.change);
this.modelLoader = new ModelLoader(service, model);
this.model = model;
}
this.hidden = shouldHide(
this.change,
this.patchRange,
this.allFilesApproved,
this.userRole
);
}
protected onModelUpdate() {
this.modelLoader?.loadUserRole();
this.modelLoader?.loadAllFilesApproved();
this.modelLoader?.loadFilesOwners();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
super(...args);
}
}
return Mixin as unknown as T & Constructor<CommonInterface>;
};
const common = CommonMixin(LitElement);
export const FILE_OWNERS_COLUMN_HEADER = 'file-owners-column-header';
@customElement(FILE_OWNERS_COLUMN_HEADER)
export class FileOwnersColumnHeader extends common {
static override get styles() {
return [
window?.Gerrit?.styles.font as CSSResult,
css`
:host() {
display: block;
padding-right: var(--spacing-m);
width: 4em;
}
:host[hidden] {
display: none;
}
`,
];
}
override render() {
if (this.hidden) return nothing;
return html`<div>Status</div>`;
}
}
/**
* It has to be part of this file as components defined in dedicated files are not visible
*/
@customElement('gr-owner')
export class GrOwner extends LitElement {
@property({type: Object})
owner?: AccountInfo;
@property({type: Object})
approval?: ApprovalInfo;
@property({type: Object})
info?: LabelInfo;
@property({type: String})
email?: EmailAddress;
static override get styles() {
return [
css`
.container {
display: flex;
}
gr-vote-chip {
margin-left: 5px;
--gr-vote-chip-width: 14px;
--gr-vote-chip-height: 14px;
}
gr-tooltip-content {
display: inline-block;
}
`,
];
}
override render() {
if (!this.owner) {
return nothing;
}
const voteChip = this.approval
? html` <gr-vote-chip
slot="vote-chip"
.vote=${this.approval}
.label=${this.info}
circle-shape
></gr-vote-chip>`
: nothing;
const copyEmail = this.email
? html` <gr-copy-clipboard
.text=${this.email}
hasTooltip
hideinput
buttonTitle=${"Copy owner's email to clipboard"}
></gr-copy-clipboard>`
: nothing;
return html`
<div class="container">
<gr-account-label .account=${this.owner}></gr-account-label>
${voteChip} ${copyEmail}
</div>
`;
}
override async updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has('owner')) {
if (this.owner && !this.email) {
const accountLabel = query<GrAccountLabel>(this, 'gr-account-label');
if (!accountLabel) {
return;
}
const updateOwner = await accountLabel
?.getAccountsModel()
?.fillDetails(this.owner);
this.email = updateOwner?.email;
}
}
}
}
export const FILE_OWNERS_COLUMN_CONTENT = 'file-owners-column-content';
@customElement(FILE_OWNERS_COLUMN_CONTENT)
export class FileOwnersColumnContent extends common {
@property({type: String})
path?: string;
@property({type: String})
oldPath?: string;
@property({type: String, reflect: true, attribute: 'file-status'})
fileStatus?: string;
private owners?: AccountInfo[];
// taken from Gerrit's common-util.ts
private uniqueId = Math.random().toString(36).substring(2);
static override get styles() {
return [
window?.Gerrit?.styles.font as CSSResult,
css`
:host {
display: flex;
padding-right: var(--spacing-m);
width: 4em;
text-align: center;
}
:host[hidden] {
display: none;
}
gr-icon {
padding: var(--spacing-xs) 0px;
}
:host([file-status='missing']) gr-icon.status {
color: #ffa62f;
}
`,
];
}
override render() {
if (this.hidden || !this.fileStatus) {
return nothing;
}
const icon = STATUS_ICON[this.fileStatus];
return html`
<gr-icon
id="${this.pathId()}"
class="status"
icon=${icon}
aria-hidden="true"
></gr-icon>
${this.renderFileOwners()}
`;
}
private pathId(): string {
return `path-${this.uniqueId}`;
}
private renderFileOwners() {
const owners = this.owners ?? [];
const splicedOwners = owners.splice(0, DISPLAY_OWNERS_FOR_FILE_LIMIT);
const showEllipsis = owners.length > DISPLAY_OWNERS_FOR_FILE_LIMIT;
// inlining <style> here is ugly but an alternative would be to copy the `HovercardMixin` from Gerrit and implement hoover from scratch
return html`<gr-hovercard for="${this.pathId()}">
<style>
#file-owners-hoovercard {
min-width: 256px;
max-width: 256px;
margin: -10px;
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
}
h3 {
font: inherit;
}
.heading-3 {
margin-left: -2px;
font-family: var(--header-font-family);
font-size: var(--font-size-h3);
font-weight: var(--font-weight-h3);
line-height: var(--line-height-h3);
}
.row {
display: flex;
}
div.section {
margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
display: flex;
align-items: center;
}
div.sectionIcon {
flex: 0 0 30px;
}
div.sectionIcon gr-icon {
position: relative;
}
div.sectionContent .row {
margin-left: 2px;
}
div.sectionContent .notLast {
margin-bottom: 2px;
}
div.sectionContent .ellipsis {
margin-left: 5px;
}
</style>
<div id="file-owners-hoovercard">
<div class="section">
<div class="sectionIcon">
<gr-icon class="status" icon="info" aria-hidden="true"></gr-icon>
</div>
<div class="sectionContent">
<h3 class="name heading-3">
<span>Needs Owners' Approval</span>
</h3>
</div>
</div>
<div class="section">
<div class="sectionContent">
${splicedOwners.map((owner, idx) => {
const [approval, info] =
computeApprovalAndInfo(
owner,
this.filesOwners?.owners_labels ?? {},
this.change
) ?? [];
return html`
<div
class="row ${showEllipsis || idx + 1 < splicedOwners.length
? 'notLast'
: ''}"
>
<gr-owner
.owner=${owner}
.approval=${approval}
.info=${info}
></gr-owner>
</div>
`;
})}
${showEllipsis
? html`
<gr-tooltip-content
title=${LIMITED_FILES_OWNERS_TOOLTIP}
aria-label="limited-file-onwers"
aria-description=${LIMITED_FILES_OWNERS_TOOLTIP}
has-tooltip
>
<div class="row ellipsis">...</div>
</gt-tooltip-content>`
: nothing}
</div>
</div>
</div>
</gr-hovercard>`;
}
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
this.computeFileState();
}
private computeFileState(): void {
const fileOwnership = getFileOwnership(
this.path,
this.allFilesApproved,
this.filesOwners
);
if (
!fileOwnership ||
fileOwnership.fileStatus === FileStatus.NOT_OWNED_OR_APPROVED
) {
this.fileStatus = undefined;
this.owners = undefined;
return;
}
this.fileStatus = FILE_STATUS[fileOwnership.fileStatus];
// TODO for the time being filter out or group owners - to be decided what/how to display them
this.owners = (fileOwnership.owners ?? [])
.filter(isOwner)
.map(o => ({_account_id: o.id} as unknown as AccountInfo));
}
}
export function shouldHide(
change?: ChangeInfo,
patchRange?: PatchRange,
allFilesApproved?: boolean,
userRole?: UserRole
): boolean {
// don't show owners when no change or change is merged
if (change === undefined || patchRange === undefined) {
return true;
}
if (
change.status === ChangeStatus.ABANDONED ||
change.status === ChangeStatus.MERGED
) {
return true;
}
// Note: in some special cases, patchNum is undefined on latest patchset
// like after publishing the edit, still show for them
// TODO: this should be fixed in Gerrit
if (patchRange?.patchNum === undefined) return false;
// only show if its latest patchset
const latestPatchset = change.revisions![change.current_revision!];
if (`${patchRange.patchNum}` !== `${latestPatchset._number}`) {
return true;
}
// show owners when they apply to the change and for logged in user
if (
!allFilesApproved &&
change.submit_requirements &&
change.submit_requirements.find(r => r.name === OWNERS_SUBMIT_REQUIREMENT)
) {
return !userRole || userRole === UserRole.ANONYMOUS;
}
return true;
}
export function getFileOwnership(
path?: string,
allFilesApproved?: boolean,
filesOwners?: FilesOwners
): FileOwnership | undefined {
if (path === undefined || filesOwners === undefined) {
return undefined;
}
const fileOwners = (filesOwners.files ?? {})[path];
return (allFilesApproved || !fileOwners
? {fileStatus: FileStatus.NOT_OWNED_OR_APPROVED}
: {
fileStatus: FileStatus.NEEDS_APPROVAL,
owners: fileOwners,
}) as unknown as FileOwnership;
}
export function computeApprovalAndInfo(
fileOwner: AccountInfo,
labels: OwnersLabels,
change?: ChangeInfo
): [ApprovalInfo, LabelInfo] | undefined {
if (!change?.labels) {
return;
}
const ownersLabel = labels[`${fileOwner._account_id}`];
if (!ownersLabel) {
return;
}
for (const label of Object.keys(ownersLabel)) {
const info = change.labels[label];
if (!info || !isDetailedLabelInfo(info)) {
return;
}
const vote = ownersLabel[label];
if ((info.default_value && info.default_value === vote) || vote === 0) {
// ignore default value
return;
}
const approval = info.all?.filter(
x => x._account_id === fileOwner._account_id
)[0];
return approval ? [approval, info] : undefined;
}
return;
}