blob: 86d4bc5e9e68435b5b143a09ae30533843287cfd [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/iron-input/iron-input';
import '../../shared/gr-account-label/gr-account-label';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-list-view/gr-list-view';
import '../../shared/gr-overlay/gr-overlay';
import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
import {encodeURL} from '../../../utils/url-util';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
import {
BranchInfo,
GitPersonInfo,
GitRef,
ProjectInfo,
RepoName,
TagInfo,
WebLinkInfo,
} from '../../../types/common';
import {firePageError} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
import {formStyles} from '../../../styles/gr-form-styles';
import {tableStyles} from '../../../styles/gr-table-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, query, property, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
import {assertIsDefined} from '../../../utils/common-util';
import {ifDefined} from 'lit/directives/if-defined.js';
import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
const PGP_START = '-----BEGIN PGP SIGNATURE-----';
@customElement('gr-repo-detail-list')
export class GrRepoDetailList extends LitElement {
@query('#overlay') private readonly overlay?: GrOverlay;
@query('#createOverlay') private readonly createOverlay?: GrOverlay;
@query('#createNewModal')
private readonly createNewModal?: GrCreatePointerDialog;
@property({type: Object})
params?: RepoViewState;
// private but used in test
@state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
// private but used in test
@state() isOwner = false;
@state() private loggedIn = false;
@state() private offset = 0;
// private but used in test
@state() repo?: RepoName;
// private but used in test
@state() items?: BranchInfo[] | TagInfo[];
@state() private readonly itemsPerPage = 25;
@state() private loading = true;
@state() private filter?: string;
@state() private refName?: GitRef;
@state() private newItemName = false;
// private but used in test
@state() isEditing = false;
// private but used in test
@state() revisedRef?: GitRef;
private readonly restApiService = getAppContext().restApiService;
static override get styles() {
return [
formStyles,
tableStyles,
sharedStyles,
css`
.tags td.name {
min-width: 25em;
}
td.name,
td.revision,
td.message {
word-break: break-word;
}
td.revision.tags {
width: 27em;
}
td.message,
td.tagger {
max-width: 15em;
}
.editing .editItem {
display: inherit;
}
.editItem,
.editing .editBtn,
.canEdit .revisionNoEditing,
.editing .revisionWithEditing,
.revisionEdit,
.hideItem {
display: none;
}
.revisionEdit gr-button {
margin-left: var(--spacing-m);
}
.editBtn {
margin-left: var(--spacing-l);
}
.canEdit .revisionEdit {
align-items: center;
display: flex;
}
.deleteButton:not(.show) {
display: none;
}
.tagger.hide {
display: none;
}
`,
];
}
override render() {
return html`
<gr-list-view
.createNew=${this.loggedIn}
.filter=${this.filter}
.itemsPerPage=${this.itemsPerPage}
.items=${this.items}
.loading=${this.loading}
.offset=${this.offset}
.path=${this.getPath(this.repo, this.detailType)}
@create-clicked=${() => {
this.handleCreateClicked();
}}
>
<table id="list" class="genericList gr-form-styles">
<tbody>
<tr class="headerRow">
<th class="name topHeader">Name</th>
<th class="revision topHeader">Revision</th>
<th
class="message topHeader ${this.detailType ===
RepoDetailView.BRANCHES
? 'hideItem'
: ''}"
>
Message
</th>
<th
class="tagger topHeader ${this.detailType ===
RepoDetailView.BRANCHES
? 'hideItem'
: ''}"
>
Tagger
</th>
<th class="repositoryBrowser topHeader">Repository Browser</th>
<th class="delete topHeader"></th>
</tr>
<tr
id="loading"
class="loadingMsg ${this.loading ? 'loading' : ''}"
>
<td>Loading...</td>
</tr>
</tbody>
<tbody class=${this.loading ? 'loading' : ''}>
${this.items
?.slice(0, SHOWN_ITEMS_COUNT)
.map((item, index) => this.renderItemList(item, index))}
</tbody>
</table>
<gr-overlay id="overlay" with-backdrop>
<gr-confirm-delete-item-dialog
class="confirmDialog"
.item=${this.refName}
.itemTypeName=${this.computeItemName(this.detailType)}
@confirm=${() => this.handleDeleteItemConfirm()}
@cancel=${() => {
this.handleConfirmDialogCancel();
}}
></gr-confirm-delete-item-dialog>
</gr-overlay>
</gr-list-view>
<gr-overlay id="createOverlay" with-backdrop>
<gr-dialog
id="createDialog"
?disabled=${!this.newItemName}
confirm-label="Create"
@confirm=${() => {
this.handleCreateItem();
}}
@cancel=${() => {
this.handleCloseCreate();
}}
>
<div class="header" slot="header">
Create ${this.computeItemName(this.detailType)}
</div>
<div class="main" slot="main">
<gr-create-pointer-dialog
id="createNewModal"
.detailType=${this.computeItemName(this.detailType)}
.itemDetail=${this.detailType}
.repoName=${this.repo}
@update-item-name=${() => {
this.handleUpdateItemName();
}}
></gr-create-pointer-dialog>
</div>
</gr-dialog>
</gr-overlay>
`;
}
private renderItemList(item: BranchInfo | TagInfo, index: number) {
return html`
<tr class="table">
<td class="${this.detailType} name">
<a href=${ifDefined(this.computeFirstWebLink(item))}>
${this.stripRefs(item.ref, this.detailType)}
</a>
</td>
<td
class="${this.detailType} revision ${this.computeCanEditClass(
item.ref,
this.detailType,
this.isOwner
)}"
>
<span class="revisionNoEditing"> ${item.revision} </span>
<span class="revisionEdit ${this.isEditing ? 'editing' : ''}">
<span class="revisionWithEditing"> ${item.revision} </span>
<gr-button
class="editBtn"
link
data-index=${index}
@click=${() => {
this.handleEditRevision(index);
}}
>
edit
</gr-button>
<iron-input
class="editItem"
.bindValue=${this.revisedRef}
@bind-value-changed=${this.handleRevisedRefBindValueChanged}
>
<input />
</iron-input>
<gr-button
class="cancelBtn editItem"
link
@click=${() => {
this.handleCancelRevision();
}}
>
Cancel
</gr-button>
<gr-button
class="saveBtn editItem"
link
data-index=${index}
?disabled=${!this.revisedRef}
@click=${() => {
this.handleSaveRevision(index);
}}
>
Save
</gr-button>
</span>
</td>
<td
class="message ${this.detailType === RepoDetailView.BRANCHES
? 'hideItem'
: ''}"
>
${(item as TagInfo)?.message
? (item as TagInfo).message?.split(PGP_START)[0]
: ''}
</td>
<td
class="tagger ${this.detailType === RepoDetailView.BRANCHES
? 'hideItem'
: ''}"
>
${this.renderTagger((item as TagInfo).tagger)}
</td>
<td class="repositoryBrowser">
${this.computeWeblink(item).map(link => this.renderWeblink(link))}
</td>
<td class="delete">
<gr-button
class="deleteButton ${item.can_delete ? 'show' : ''}"
link
data-index=${index}
@click=${() => {
this.handleDeleteItem(index);
}}
>
Delete
</gr-button>
</td>
</tr>
`;
}
private renderTagger(tagger?: GitPersonInfo) {
if (!tagger) return;
return html`
<div class="tagger">
<gr-account-label .account=${tagger} clickable> </gr-account-label>
(<gr-date-formatter withTooltip .dateStr=${tagger.date}>
</gr-date-formatter
>)
</div>
`;
}
private renderWeblink(link: WebLinkInfo) {
return html`
<a href=${link.url} class="webLink" rel="noopener" target="_blank">
(${link.name})
</a>
`;
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('params')) {
this.paramsChanged();
}
}
// private but used in test
determineIfOwner(repo: RepoName) {
return this.restApiService
.getRepoAccess(repo)
.then(access => (this.isOwner = !!access?.[repo]?.is_owner));
}
// private but used in test
paramsChanged() {
if (!this.params?.repo) {
return Promise.reject(new Error('undefined repo'));
}
// paramsChanged is called before gr-admin-view can set _showRepoDetailList
// to false and polymer removes this component, hence check for params
if (
!(
this.params?.detail === RepoDetailView.BRANCHES ||
this.params?.detail === RepoDetailView.TAGS
)
) {
return;
}
this.repo = this.params.repo;
this.getLoggedIn().then(loggedIn => {
this.loggedIn = loggedIn;
if (loggedIn && this.repo) {
this.determineIfOwner(this.repo);
}
});
this.detailType = this.params.detail;
this.filter = this.params?.filter ?? '';
this.offset = Number(this.params?.offset ?? 0);
if (!this.detailType)
return Promise.reject(new Error('undefined detailType'));
return this.getItems(
this.filter,
this.repo,
this.itemsPerPage,
this.offset,
this.detailType
);
}
// TODO(TS) Move this to object for easier read, understand.
private getItems(
filter: string | undefined,
repo: RepoName | undefined,
itemsPerPage: number,
offset: number | undefined,
detailType?: string
) {
if (filter === undefined || !repo || offset === undefined) {
return Promise.reject(new Error('filter or repo or offset undefined'));
}
this.loading = true;
this.items = [];
const errFn: ErrorCallback = response => {
firePageError(response);
};
if (detailType === RepoDetailView.BRANCHES) {
return this.restApiService
.getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
.then(items => {
this.items = items ?? [];
this.loading = false;
})
.finally(() => {
this.loading = false;
});
} else if (detailType === RepoDetailView.TAGS) {
return this.restApiService
.getRepoTags(filter, repo, itemsPerPage, offset, errFn)
.then(items => {
this.items = items ?? [];
})
.finally(() => {
this.loading = false;
});
}
return Promise.reject(new Error('unknown detail type'));
}
private getPath(repo?: RepoName, detailType?: RepoDetailView) {
return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
}
private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
if (!repo.web_links) return [];
const webLinks = repo.web_links;
return webLinks.length ? webLinks : [];
}
private computeFirstWebLink(repo: ProjectInfo | BranchInfo | TagInfo) {
const webLinks = this.computeWeblink(repo);
return webLinks.length > 0 ? webLinks[0].url : undefined;
}
// private but used in test
stripRefs(item: GitRef, detailType?: RepoDetailView) {
if (detailType === RepoDetailView.BRANCHES) {
return item.replace('refs/heads/', '');
} else if (detailType === RepoDetailView.TAGS) {
return item.replace('refs/tags/', '');
}
throw new Error('unknown detailType');
}
// private but used in test
getLoggedIn() {
return this.restApiService.getLoggedIn();
}
private computeCanEditClass(
ref?: GitRef,
detailType?: RepoDetailView,
isOwner?: boolean
) {
if (ref === undefined || detailType === undefined) return '';
return isOwner && this.stripRefs(ref, detailType) === 'HEAD'
? 'canEdit'
: '';
}
private handleEditRevision(index: number) {
if (!this.items) return;
this.revisedRef = this.items[index].revision as GitRef;
this.isEditing = true;
}
private handleCancelRevision() {
this.isEditing = false;
}
// private but used in test
handleSaveRevision(index: number) {
if (this.revisedRef && this.repo)
this.setRepoHead(this.repo, this.revisedRef, index);
}
// private but used in test
setRepoHead(repo: RepoName, ref: GitRef, index: number) {
if (!this.items) return;
return this.restApiService.setRepoHead(repo, ref).then(res => {
if (res.status < 400) {
this.isEditing = false;
this.items![index].revision = ref;
// This is needed to refresh 'items' property with fresh data,
// specifically can_delete from the json response.
this.getItems(
this.filter,
this.repo,
this.itemsPerPage,
this.offset,
this.detailType
);
}
});
}
// private but used in test
computeItemName(detailType?: RepoDetailView) {
if (detailType === undefined) return '';
if (detailType === RepoDetailView.BRANCHES) {
return 'Branch';
} else if (detailType === RepoDetailView.TAGS) {
return 'Tag';
}
throw new Error('unknown detailType');
}
private handleDeleteItemConfirm() {
assertIsDefined(this.overlay, 'overlay');
this.overlay.close();
if (!this.repo || !this.refName) {
return Promise.reject(new Error('undefined repo or refName'));
}
if (this.detailType === RepoDetailView.BRANCHES) {
return this.restApiService
.deleteRepoBranches(this.repo, this.refName)
.then(itemDeleted => {
if (itemDeleted.status === 204) {
this.getItems(
this.filter,
this.repo,
this.itemsPerPage,
this.offset,
this.detailType
);
}
});
} else if (this.detailType === RepoDetailView.TAGS) {
return this.restApiService
.deleteRepoTags(this.repo, this.refName)
.then(itemDeleted => {
if (itemDeleted.status === 204) {
this.getItems(
this.filter,
this.repo,
this.itemsPerPage,
this.offset,
this.detailType
);
}
});
}
return Promise.reject(new Error('unknown detail type'));
}
private handleConfirmDialogCancel() {
assertIsDefined(this.overlay, 'overlay');
this.overlay.close();
}
private handleDeleteItem(index: number) {
if (!this.items) return;
assertIsDefined(this.overlay, 'overlay');
const name = this.stripRefs(
this.items[index].ref,
this.detailType
) as GitRef;
if (!name) return;
this.refName = name;
this.overlay.open();
}
// private but used in test
handleCreateItem() {
assertIsDefined(this.createNewModal, 'createNewModal');
this.createNewModal.handleCreateItem();
this.handleCloseCreate();
}
// private but used in test
handleCloseCreate() {
assertIsDefined(this.createOverlay, 'createOverlay');
this.createOverlay.close();
}
// private but used in test
handleCreateClicked() {
assertIsDefined(this.createOverlay, 'createOverlay');
this.createOverlay.open();
}
private handleUpdateItemName() {
assertIsDefined(this.createNewModal, 'createNewModal');
this.newItemName = !!this.createNewModal.itemName;
}
private handleRevisedRefBindValueChanged(e: BindValueChangeEvent) {
this.revisedRef = e.detail.value as GitRef;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-repo-detail-list': GrRepoDetailList;
}
}