blob: 428e85db499da2d76caab42e2b3ad620f42ba8ec [file]
/**
* @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 {
css,
html,
LitElement,
PropertyValues,
nothing,
CSSResult,
unsafeCSS,
} from 'lit';
import {ifDefined} from 'lit/directives/if-defined.js';
import {OwnersMixin} from './owners-mixin';
import {customElement, property} from 'lit/decorators.js';
import {
AccountInfo,
ChangeInfo,
ChangeStatus,
EmailAddress,
RevisionInfo,
EDIT,
} from '@gerritcodereview/typescript-api/rest-api';
import {User, UserRole} from './owners-model';
import {
FilesOwners,
hasOwnersSubmitRequirement,
isOwner,
OwnedFiles,
} from './owners-service';
import {
computeDisplayPath,
diffFilePaths,
encodeURL,
getBaseUrl,
truncatePath,
} from './utils';
export enum FileStatus {
NEEDS_APPROVAL = 'missing',
APPROVED = 'approved',
AUTO_APPROVED = 'autoApproved',
}
const STATUS_ICON = {
[FileStatus.NEEDS_APPROVAL]: 'schedule',
[FileStatus.APPROVED]: 'check',
[FileStatus.AUTO_APPROVED]: 'published_with_changes',
};
interface OwnedFileInfo {
status: FileStatus;
file: string;
}
export interface OwnedFilesInfo {
ownedFiles: OwnedFileInfo[];
numberOfPending: number;
numberOfApproved: number;
}
const common = OwnersMixin(LitElement);
class OwnedFilesCommon extends common {
@property({type: Object})
revision?: RevisionInfo;
protected ownedFilesInfo?: OwnedFilesInfo;
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
this.computeOwnedFiles();
this.hidden = shouldHide(
this.change,
this.revision,
this.user,
this.ownedFilesInfo?.ownedFiles
);
}
static commonStyles(): CSSResult[] {
const template = document.querySelector(
'dom-module#shared-styles > template'
) as HTMLTemplateElement;
const sharedStyles = css`
${unsafeCSS(template?.content?.querySelector('style')?.textContent)}
`;
return [sharedStyles, window?.Gerrit?.styles.font as CSSResult];
}
private computeOwnedFiles() {
this.ownedFilesInfo = ownedFiles(this.user?.account, this.filesOwners);
}
}
export const OWNED_FILES_TAB_HEADER = 'owned-files-tab-header';
@customElement(OWNED_FILES_TAB_HEADER)
export class OwnedFilesTabHeader extends OwnedFilesCommon {
@property({type: String, reflect: true, attribute: 'files-status'})
filesStatus?: FileStatus;
private ownedFiles: number | undefined;
static override get styles() {
return [
...OwnedFilesCommon.commonStyles(),
css`
[hidden] {
display: none;
}
gr-icon {
padding: var(--spacing-xs) 0px;
margin-left: 3px;
}
:host([files-status='approved']) gr-icon.status,
:host([files-status='autoApproved']) gr-icon.status {
color: var(--positive-green-text-color);
}
:host([files-status='missing']) gr-icon.status {
color: #ffa62f;
}
`,
];
}
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (this.hidden || this.ownedFilesInfo === undefined) {
this.filesStatus = undefined;
this.ownedFiles = undefined;
} else {
[this.filesStatus, this.ownedFiles] =
this.ownedFilesInfo.numberOfPending > 0
? [FileStatus.NEEDS_APPROVAL, this.ownedFilesInfo.numberOfPending]
: [FileStatus.APPROVED, this.ownedFilesInfo.numberOfApproved];
}
}
override render() {
// even if `nothing` is returned Gerrit still shows the pointer and allows
// clicking at it, redirecting to the empty tab when done; traverse through
// the shadowRoots down to the tab and disable/enable it when needed
const tabParent = document
.querySelector('#pg-app')
?.shadowRoot?.querySelector('#app-element')
?.shadowRoot?.querySelector('main > gr-change-view')
?.shadowRoot?.querySelector(
'#tabs > paper-tab[data-name="change-view-tab-header-owners"]'
);
if (this.hidden) {
if (tabParent && !tabParent.getAttribute('disabled')) {
tabParent.setAttribute('disabled', 'disabled');
}
return nothing;
}
if (tabParent && tabParent.getAttribute('disabled')) {
tabParent.removeAttribute('disabled');
}
if (
this.ownedFilesInfo === undefined ||
this.filesStatus === undefined ||
this.ownedFiles === undefined
) {
return html`<div>Owned Files</div>`;
}
const icon = STATUS_ICON[this.filesStatus];
const [info, summary] = this.buildInfoAndSummary(
this.filesStatus,
this.ownedFilesInfo.numberOfApproved,
this.ownedFilesInfo.numberOfPending
);
return html` <div>
Owned Files
<gr-tooltip-content
title=${info}
aria-label=${summary}
aria-description=${info}
has-tooltip
>
<gr-icon
id="owned-files-status"
class="status"
icon=${icon}
aria-hidden="true"
></gr-icon>
</gr-tooltip-content>
</div>`;
}
private buildInfoAndSummary(
filesStatus: FileStatus,
filesApproved: number,
filesPending: number
): [string, string] {
const allFilesApproved =
filesStatus === FileStatus.APPROVED ||
filesStatus === FileStatus.AUTO_APPROVED;
const pendingInfo =
filesPending > 0
? `Missing approval for ${filesPending} file${
filesPending > 1 ? 's' : ''
}`
: '';
const approvedInfo =
filesApproved > 0
? `${filesApproved} file${
filesApproved > 1 ? 's' : ''
} already approved.`
: '';
const info = `${
allFilesApproved
? approvedInfo
: `${pendingInfo}${filesApproved > 0 ? ` and ${approvedInfo}` : '.'}`
}`;
const summary = `${allFilesApproved ? 'Approved' : 'Missing'}`;
return [info, summary];
}
}
@customElement('gr-owned-files-list')
export class GrOwnedFilesList extends LitElement {
@property({type: Object})
change?: ChangeInfo;
@property({type: Object})
revision?: RevisionInfo;
@property({type: Array})
ownedFiles?: OwnedFileInfo[];
static override get styles() {
return [
...OwnedFilesCommon.commonStyles(),
css`
:host {
display: block;
}
.row {
align-items: center;
border-top: 1px solid var(--border-color);
display: flex;
min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
padding: var(--spacing-xs) var(--spacing-l);
}
.header-row {
padding-left: var(--spacing-m);
background-color: var(--background-color-secondary);
}
.file-row {
padding-left: var(--spacing-m);
cursor: pointer;
}
.file-row:hover {
background-color: var(--hover-background-color);
}
.file-row.selected {
background-color: var(--selection-background-color);
}
.status {
padding: inherit;
}
.path {
padding: inherit;
cursor: pointer;
flex: 1;
/* Wrap it into multiple lines if too long. */
white-space: normal;
word-break: break-word;
}
gr-icon.status.approved {
color: var(--positive-green-text-color);
}
gr-icon.status.autoApproved {
color: var(--positive-green-text-color);
}
gr-icon.status.missing {
color: #ffa62f;
}
.matchingFilePath {
color: var(--deemphasized-text-color);
}
.newFilePath {
color: var(--primary-text-color);
}
.fileName {
color: var(--link-color);
}
.truncatedFileName {
display: none;
}
.row:focus {
outline: none;
}
.row:hover {
opacity: 100;
}
.pathLink {
display: inline-block;
margin: -2px 0;
padding: var(--spacing-s) 0;
text-decoration: none;
}
.pathLink:hover span.fullFileName,
.pathLink:hover span.truncatedFileName {
text-decoration: underline;
}
`,
];
}
override render() {
if (!this.change || !this.revision || !this.ownedFiles) return nothing;
return html`
<div id="container" role="grid" aria-label="Owned files list">
${this.renderOwnedFilesHeaderRow()}
${this.ownedFiles?.map((ownedFile, index) =>
this.renderOwnedFileRow(ownedFile, index)
)}
</div>
`;
}
private renderOwnedFilesHeaderRow() {
return html`
<div class="header-row row" role="row">
<div class="status" role="columnheader">Status</div>
<div class="path" role="columnheader">File</div>
</div>
`;
}
private renderOwnedFileRow(ownedFile: OwnedFileInfo, index: number) {
return html`
<div
class="file-row row"
tabindex="-1"
role="row"
aria-label=${ownedFile.file}
>
${this.renderFileStatus(ownedFile.status)}
${this.renderFilePath(ownedFile.file, index)}
</div>
`;
}
private renderFileStatus(status: FileStatus) {
const icon = STATUS_ICON[status];
return html`
<span class="status" role="gridcell">
<gr-icon
class="status ${status}"
icon=${icon}
aria-hidden="true"
></gr-icon>
</span>
`;
}
private renderFilePath(file: string, index: number) {
const displayPath = computeDisplayPath(file);
const previousFile = (this.ownedFiles ?? [])[index - 1];
return html`
<span class="path" role="gridcell">
<a
class="pathLink"
href=${ifDefined(computeDiffUrl(file, this.change, this.revision))}
>
<span title=${displayPath} class="fullFileName">
${this.renderStyledPath(file, previousFile?.file)}
</span>
<span title=${displayPath} class="truncatedFileName">
${truncatePath(displayPath)}
</span>
</a>
</span>
`;
}
private renderStyledPath(filePath: string, previousFilePath?: string) {
const {matchingFolders, newFolders, fileName} = diffFilePaths(
filePath,
previousFilePath
);
return [
matchingFolders.length > 0
? html`<span class="matchingFilePath">${matchingFolders}</span>`
: nothing,
newFolders.length > 0
? html`<span class="newFilePath">${newFolders}</span>`
: nothing,
html`<span class="fileName">${fileName}</span>`,
];
}
}
export const OWNED_FILES_TAB_CONTENT = 'owned-files-tab-content';
@customElement(OWNED_FILES_TAB_CONTENT)
export class OwnedFilesTabContent extends OwnedFilesCommon {
static override get styles() {
return [
...OwnedFilesCommon.commonStyles(),
css`
:host {
display: block;
}
`,
];
}
override render() {
if (this.hidden) return nothing;
return html`
<gr-owned-files-list
id="ownedFilesList"
.change=${this.change}
.revision=${this.revision}
.ownedFiles=${this.ownedFilesInfo?.ownedFiles}
>
</gr-owned-files-list>
`;
}
}
export function shouldHide(
change?: ChangeInfo,
revision?: RevisionInfo,
user?: User,
ownedFiles?: OwnedFileInfo[]
) {
// don't show owned files when no change or change is abandoned/merged or being edited or viewing not current PS
if (
change === undefined ||
change.status === ChangeStatus.ABANDONED ||
change.status === ChangeStatus.MERGED ||
revision === undefined ||
revision._number === EDIT ||
change.current_revision !== revision.commit?.commit
) {
return true;
}
// show owned files if user owns anything
if (hasOwnersSubmitRequirement(change)) {
return (
!user ||
user.role === UserRole.ANONYMOUS ||
(ownedFiles ?? []).length === 0
);
}
return true;
}
function collectOwnedFiles(
owner: AccountInfo,
groupPrefix: string,
files: OwnedFiles,
fileStatus: FileStatus,
emailWithoutDomain?: string
): OwnedFileInfo[] {
const ownedFiles = [];
for (const file of Object.keys(files ?? [])) {
if (
files[file].find(fileOwner => {
if (isOwner(fileOwner)) {
return fileOwner.id === owner._account_id;
}
return (
!fileOwner.name?.startsWith(groupPrefix) &&
(fileOwner.name === emailWithoutDomain ||
fileOwner.name === owner.name)
);
})
) {
ownedFiles.push({file, status: fileStatus} as OwnedFileInfo);
}
}
return ownedFiles;
}
export function ownedFiles(
owner?: AccountInfo,
filesOwners?: FilesOwners
): OwnedFilesInfo | undefined {
if (
!owner ||
!filesOwners ||
(!filesOwners.files &&
!filesOwners.files_approved &&
!filesOwners.files_auto_approved)
) {
return;
}
const groupPrefix = 'group/';
const emailWithoutDomain = toEmailWithoutDomain(owner.email);
const pendingFiles = collectOwnedFiles(
owner,
groupPrefix,
filesOwners.files,
FileStatus.NEEDS_APPROVAL,
emailWithoutDomain
);
const approvedFiles = collectOwnedFiles(
owner,
groupPrefix,
filesOwners.files_approved,
FileStatus.APPROVED,
emailWithoutDomain
);
const autoApprovedFiles = collectOwnedFiles(
owner,
groupPrefix,
filesOwners.files_auto_approved ?? {},
FileStatus.AUTO_APPROVED,
emailWithoutDomain
);
return {
ownedFiles: [...pendingFiles, ...approvedFiles, ...autoApprovedFiles],
numberOfPending: pendingFiles.length,
numberOfApproved: approvedFiles.length + autoApprovedFiles.length,
} as OwnedFilesInfo;
}
export function computeDiffUrl(
file: string,
change?: ChangeInfo,
revision?: RevisionInfo
) {
if (change === undefined || revision?._number === undefined) {
return;
}
let repo = '';
if (change.project) repo = `${encodeURL(change.project)}/+/`;
// TODO it can be a range of patchsets but `PatchRange` is not passed to the `change-view-tab-content` :/ fix in Gerrit?
const range = `/${revision._number}`;
const path = `/${encodeURL(file)}`;
return `${getBaseUrl()}/c/${repo}${change._number}${range}${path}`;
}
function toEmailWithoutDomain(email?: EmailAddress): string | undefined {
const startDomainIndex = email?.indexOf('@');
return startDomainIndex ? email?.substring(0, startDomainIndex) : undefined;
}