| /** |
| * @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-weblink/gr-weblink'; |
| import '../gr-create-pointer-dialog/gr-create-pointer-dialog'; |
| import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog'; |
| 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 {grFormStyles} from '../../../styles/gr-form-styles'; |
| import {tableStyles} from '../../../styles/gr-table-styles'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {LitElement, PropertyValues, css, html, nothing} 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 { |
| createRepoUrl, |
| RepoDetailView, |
| RepoViewState, |
| } from '../../../models/views/repo'; |
| import {modalStyles} from '../../../styles/gr-modal-styles'; |
| |
| const PGP_START = '-----BEGIN PGP SIGNATURE-----'; |
| |
| @customElement('gr-repo-detail-list') |
| export class GrRepoDetailList extends LitElement { |
| @query('#modal') private readonly modal?: HTMLDialogElement; |
| |
| @query('#createModal') private readonly createModal?: HTMLDialogElement; |
| |
| @query('#createNewModal') |
| private readonly createNewModal?: GrCreatePointerDialog; |
| |
| @property({type: Object}) |
| params?: RepoViewState; |
| |
| @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS; |
| |
| @state() isOwner = false; |
| |
| @state() loggedIn = false; |
| |
| @state() offset = 0; |
| |
| @state() repo?: RepoName; |
| |
| @state() items?: BranchInfo[] | TagInfo[]; |
| |
| @state() readonly itemsPerPage = 25; |
| |
| @state() loading = true; |
| |
| @state() filter?: string; |
| |
| @state() refName?: GitRef; |
| |
| @state() newItemName = false; |
| |
| @state() isEditing = false; |
| |
| @state() revisedRef?: GitRef; |
| |
| private readonly restApiService = getAppContext().restApiService; |
| |
| static override get styles() { |
| return [ |
| grFormStyles, |
| tableStyles, |
| sharedStyles, |
| modalStyles, |
| 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() { |
| if (!this.repo) return nothing; |
| if (!this.detailType) return nothing; |
| 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()} |
| @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, this.itemsPerPage) |
| .map((item, index) => this.renderItemList(item, index))} |
| </tbody> |
| </table> |
| <dialog id="modal" tabindex="-1"> |
| <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> |
| </dialog> |
| </gr-list-view> |
| <dialog id="createModal" tabindex="-1"> |
| <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> |
| </dialog> |
| `; |
| } |
| |
| 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(info: WebLinkInfo) { |
| return html`<gr-weblink imageAndText .info=${info}></gr-weblink>`; |
| } |
| |
| 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() { |
| if (!this.repo) return ''; |
| if (!this.detailType) return ''; |
| return createRepoUrl({repo: this.repo, detail: this.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.modal, 'modal'); |
| this.modal.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.modal, 'modal'); |
| this.modal.close(); |
| } |
| |
| private handleDeleteItem(index: number) { |
| if (!this.items) return; |
| assertIsDefined(this.modal, 'modal'); |
| const name = this.stripRefs( |
| this.items[index].ref, |
| this.detailType |
| ) as GitRef; |
| if (!name) return; |
| this.refName = name; |
| this.modal.showModal(); |
| } |
| |
| // private but used in test |
| handleCreateItem() { |
| assertIsDefined(this.createNewModal, 'createNewModal'); |
| this.createNewModal.handleCreateItem(); |
| this.handleCloseCreate(); |
| } |
| |
| // private but used in test |
| handleCloseCreate() { |
| assertIsDefined(this.createModal, 'createModal'); |
| this.createModal.close(); |
| } |
| |
| // private but used in test |
| handleCreateClicked() { |
| assertIsDefined(this.createModal, 'createModal'); |
| this.createModal.showModal(); |
| } |
| |
| 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; |
| } |
| } |