| /** | |
| * @license | |
| * Copyright 2017 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import '../../shared/gr-dropdown-list/gr-dropdown-list'; | |
| import '../../shared/gr-icon/gr-icon'; | |
| import '../../shared/gr-page-nav/gr-page-nav'; | |
| import '../gr-admin-group-list/gr-admin-group-list'; | |
| import '../gr-group/gr-group'; | |
| import '../gr-group-audit-log/gr-group-audit-log'; | |
| import '../gr-group-members/gr-group-members'; | |
| import '../gr-plugin-list/gr-plugin-list'; | |
| import '../gr-repo/gr-repo'; | |
| import '../gr-repo-access/gr-repo-access'; | |
| import '../gr-repo-commands/gr-repo-commands'; | |
| import '../gr-repo-dashboards/gr-repo-dashboards'; | |
| import '../gr-repo-detail-list/gr-repo-detail-list'; | |
| import '../gr-repo-submit-requirements/gr-repo-submit-requirements'; | |
| import '../gr-repo-labels/gr-repo-labels'; | |
| import '../gr-repo-list/gr-repo-list'; | |
| import '../gr-server-info/gr-server-info'; | |
| import {navigationToken} from '../../core/gr-navigation/gr-navigation'; | |
| import { | |
| AccountDetailInfo, | |
| GroupId, | |
| GroupName, | |
| RepoName, | |
| } from '../../../types/common'; | |
| import {GroupNameChangedDetail} from '../gr-group/gr-group'; | |
| import {getAppContext} from '../../../services/app-context'; | |
| import { | |
| GerritView, | |
| routerModelToken, | |
| } from '../../../services/router/router-model'; | |
| import {menuPageStyles} from '../../../styles/gr-menu-page-styles'; | |
| import {pageNavStyles} from '../../../styles/gr-page-nav-styles'; | |
| import {sharedStyles} from '../../../styles/shared-styles'; | |
| import {css, html, LitElement, nothing, PropertyValues} from 'lit'; | |
| import {customElement, state} from 'lit/decorators.js'; | |
| import {ifDefined} from 'lit/directives/if-defined.js'; | |
| import {ValueChangedEvent} from '../../../types/events'; | |
| import { | |
| AdminChildView, | |
| AdminNavLinksOption, | |
| adminViewModelToken, | |
| AdminViewState, | |
| getAdminLinks, | |
| NavLink, | |
| SubsectionInterface, | |
| } from '../../../models/views/admin'; | |
| import { | |
| GroupDetailView, | |
| groupViewModelToken, | |
| GroupViewState, | |
| } from '../../../models/views/group'; | |
| import { | |
| RepoDetailView, | |
| repoViewModelToken, | |
| RepoViewState, | |
| } from '../../../models/views/repo'; | |
| import {resolve} from '../../../models/dependency'; | |
| import {subscribe} from '../../lit/subscription-controller'; | |
| import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; | |
| const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/; | |
| export interface AdminSubsectionLink { | |
| text: string; | |
| value: string; | |
| view: GerritView; | |
| url?: string; | |
| detailType?: GroupDetailView | RepoDetailView; | |
| parent?: GroupId | RepoName; | |
| } | |
| @customElement('gr-admin-view') | |
| export class GrAdminView extends LitElement { | |
| @state() | |
| view?: GerritView; | |
| @state() | |
| adminViewState?: AdminViewState; | |
| @state() | |
| groupViewState?: GroupViewState; | |
| @state() | |
| repoViewState?: RepoViewState; | |
| @state() canDeleteCapability = false; | |
| @state() private breadcrumbParentName?: string; | |
| // private but used in test | |
| @state() repoName?: RepoName; | |
| // private but used in test | |
| @state() groupId?: GroupId; | |
| // private but used in test | |
| @state() groupIsInternal?: boolean; | |
| // private but used in test | |
| @state() groupName?: GroupName; | |
| // private but used in test | |
| @state() subsectionLinks?: AdminSubsectionLink[]; | |
| // private but used in test | |
| @state() filteredLinks?: NavLink[]; | |
| private reloading = false; | |
| // private but used in the tests | |
| private readonly restApiService = getAppContext().restApiService; | |
| private readonly getPluginLoader = resolve(this, pluginLoaderToken); | |
| private readonly getAdminViewModel = resolve(this, adminViewModelToken); | |
| private readonly getGroupViewModel = resolve(this, groupViewModelToken); | |
| private readonly getRepoViewModel = resolve(this, repoViewModelToken); | |
| private readonly getRouterModel = resolve(this, routerModelToken); | |
| private readonly getNavigation = resolve(this, navigationToken); | |
| constructor() { | |
| super(); | |
| this.addEventListener('reload', () => window.location.reload()); | |
| subscribe( | |
| this, | |
| () => this.getAdminViewModel().state$, | |
| state => { | |
| this.adminViewState = state; | |
| if (this.needsReload()) this.reload(); | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getGroupViewModel().state$, | |
| state => { | |
| this.groupViewState = state; | |
| if (this.needsReload()) this.reload(); | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getRepoViewModel().state$, | |
| state => { | |
| this.repoViewState = state; | |
| if (this.needsReload()) this.reload(); | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getRouterModel().routerView$, | |
| view => { | |
| this.view = view; | |
| if (this.needsReload()) this.reload(); | |
| } | |
| ); | |
| } | |
| override connectedCallback() { | |
| super.connectedCallback(); | |
| this.reload(); | |
| } | |
| static override get styles() { | |
| return [ | |
| sharedStyles, | |
| menuPageStyles, | |
| pageNavStyles, | |
| css` | |
| .breadcrumbText { | |
| /* Same as dropdown trigger so chevron spacing is consistent. */ | |
| padding: 5px 4px; | |
| } | |
| gr-icon { | |
| margin: 0 var(--spacing-xs); | |
| } | |
| .breadcrumb { | |
| align-items: center; | |
| display: flex; | |
| } | |
| .mainHeader { | |
| align-items: baseline; | |
| border-bottom: 1px solid var(--border-color); | |
| display: flex; | |
| } | |
| .selectText { | |
| display: none; | |
| } | |
| .selectText.show { | |
| display: inline-block; | |
| } | |
| .main.breadcrumbs:not(.table) { | |
| margin-top: var(--spacing-l); | |
| } | |
| `, | |
| ]; | |
| } | |
| override render() { | |
| if (!this.isAdminView()) return nothing; | |
| return html` | |
| <gr-page-nav class="navStyles"> | |
| <ul class="sectionContent"> | |
| ${this.filteredLinks?.map(item => this.renderAdminNav(item))} | |
| </ul> | |
| </gr-page-nav> | |
| ${this.renderSubsectionLinks()} ${this.renderRepoList()} | |
| ${this.renderGroupList()} ${this.renderPluginList()} | |
| ${this.renderRepoMain()} ${this.renderGroup()} | |
| ${this.renderGroupMembers()} ${this.renderGroupAuditLog()} | |
| ${this.renderRepoDetailList()} ${this.renderRepoCommands()} | |
| ${this.renderRepoAccess()} ${this.renderRepoDashboards()} | |
| ${this.renderRepoSubmitRequirements()} ${this.renderRepoLabels()} | |
| ${this.renderServerInfo()} | |
| `; | |
| } | |
| private renderAdminNav(item: NavLink) { | |
| return html` | |
| <li class="sectionTitle ${this.computeSelectedClass(item.view)}"> | |
| <a class="title" href=${this.computeLinkURL(item)} rel="noopener" | |
| >${item.name}</a | |
| > | |
| </li> | |
| ${item.children?.map(child => this.renderAdminNavChild(child))} | |
| ${this.renderAdminNavSubsection(item)} | |
| `; | |
| } | |
| private renderAdminNavChild(child: SubsectionInterface) { | |
| return html` | |
| <li class=${this.computeSelectedClass(child.view)}> | |
| <a href=${this.computeLinkURL(child)} rel="noopener">${child.name}</a> | |
| </li> | |
| `; | |
| } | |
| private renderAdminNavSubsection(item: NavLink) { | |
| if (!item.subsection) return nothing; | |
| return html` | |
| <!--If a section has a subsection, render that.--> | |
| <li class=${this.computeSelectedClass(item.subsection.view)}> | |
| ${this.renderAdminNavSubsectionUrl(item.subsection)} | |
| </li> | |
| <!--Loop through the links in the sub-section.--> | |
| ${item.subsection?.children?.map(child => | |
| this.renderAdminNavSubsectionChild(child) | |
| )} | |
| `; | |
| } | |
| private renderAdminNavSubsectionUrl(subsection?: SubsectionInterface) { | |
| if (!subsection!.url) return html`${subsection!.name}`; | |
| return html` | |
| <a class="title" href=${this.computeLinkURL(subsection)} rel="noopener"> | |
| ${subsection!.name}</a | |
| > | |
| `; | |
| } | |
| private renderAdminNavSubsectionChild(child: SubsectionInterface) { | |
| return html` | |
| <li | |
| class="subsectionItem ${this.computeSelectedClass( | |
| child.view, | |
| child.detailType | |
| )}" | |
| > | |
| <a href=${this.computeLinkURL(child)}>${child.name}</a> | |
| </li> | |
| `; | |
| } | |
| private renderSubsectionLinks() { | |
| if (!this.subsectionLinks?.length) return nothing; | |
| return html` | |
| <section class="mainHeader"> | |
| <span class="breadcrumb"> | |
| <span class="breadcrumbText">${this.breadcrumbParentName}</span> | |
| <gr-icon icon="chevron_right"></gr-icon> | |
| </span> | |
| <gr-dropdown-list | |
| id="pageSelect" | |
| value=${ifDefined(this.computeSelectValue())} | |
| .items=${this.subsectionLinks} | |
| @value-change=${this.handleSubsectionChange} | |
| > | |
| </gr-dropdown-list> | |
| </section> | |
| `; | |
| } | |
| private renderRepoList() { | |
| if (this.view !== GerritView.ADMIN) return nothing; | |
| if (this.adminViewState?.adminView !== AdminChildView.REPOS) return nothing; | |
| return html` | |
| <div class="main table"> | |
| <gr-repo-list | |
| class="table" | |
| .params=${this.adminViewState} | |
| ></gr-repo-list> | |
| </div> | |
| `; | |
| } | |
| private renderGroupList() { | |
| if (this.view !== GerritView.ADMIN) return nothing; | |
| if (this.adminViewState?.adminView !== AdminChildView.GROUPS) | |
| return nothing; | |
| return html` | |
| <div class="main table"> | |
| <gr-admin-group-list class="table" .params=${this.adminViewState}> | |
| </gr-admin-group-list> | |
| </div> | |
| `; | |
| } | |
| private renderPluginList() { | |
| if (this.view !== GerritView.ADMIN) return nothing; | |
| if (this.adminViewState?.adminView !== AdminChildView.PLUGINS) | |
| return nothing; | |
| return html` | |
| <div class="main table"> | |
| <gr-plugin-list | |
| class="table" | |
| .params=${this.adminViewState} | |
| ></gr-plugin-list> | |
| </div> | |
| `; | |
| } | |
| private renderRepoMain() { | |
| if (this.view !== GerritView.REPO) return nothing; | |
| const detail = this.repoViewState?.detail ?? RepoDetailView.GENERAL; | |
| if (detail !== RepoDetailView.GENERAL) return nothing; | |
| return html` | |
| <div class="main breadcrumbs"> | |
| <gr-repo .repo=${this.repoViewState?.repo}></gr-repo> | |
| </div> | |
| `; | |
| } | |
| private renderGroup() { | |
| if (this.view !== GerritView.GROUP) return nothing; | |
| if (this.groupViewState?.detail !== undefined) return nothing; | |
| return html` | |
| <div class="main breadcrumbs"> | |
| <gr-group | |
| .groupId=${this.groupViewState?.groupId} | |
| .canDelete=${this.canDeleteCapability} | |
| @name-changed=${(e: CustomEvent<GroupNameChangedDetail>) => { | |
| this.updateGroupName(e); | |
| }} | |
| ></gr-group> | |
| </div> | |
| `; | |
| } | |
| private renderGroupMembers() { | |
| if (this.view !== GerritView.GROUP) return nothing; | |
| if (this.groupViewState?.detail !== GroupDetailView.MEMBERS) return nothing; | |
| return html` | |
| <div class="main breadcrumbs"> | |
| <gr-group-members | |
| .groupId=${this.groupViewState?.groupId} | |
| ></gr-group-members> | |
| </div> | |
| `; | |
| } | |
| private renderGroupAuditLog() { | |
| if (this.view !== GerritView.GROUP) return nothing; | |
| if (this.groupViewState?.detail !== GroupDetailView.LOG) return nothing; | |
| return html` | |
| <div class="main table breadcrumbs"> | |
| <gr-group-audit-log | |
| class="table" | |
| .groupId=${this.groupViewState?.groupId} | |
| ></gr-group-audit-log> | |
| </div> | |
| `; | |
| } | |
| private renderRepoDetailList() { | |
| if (this.view !== GerritView.REPO) return nothing; | |
| const detail = this.repoViewState?.detail; | |
| if (detail !== RepoDetailView.BRANCHES && detail !== RepoDetailView.TAGS) { | |
| return nothing; | |
| } | |
| return html` | |
| <div class="main table breadcrumbs"> | |
| <gr-repo-detail-list | |
| class="table" | |
| .params=${this.repoViewState} | |
| ></gr-repo-detail-list> | |
| </div> | |
| `; | |
| } | |
| private renderRepoCommands() { | |
| if (this.view !== GerritView.REPO) return nothing; | |
| if (this.repoViewState?.detail !== RepoDetailView.COMMANDS) return nothing; | |
| return html` | |
| <div class="main breadcrumbs"> | |
| <gr-repo-commands | |
| .repo=${this.repoViewState.repo} | |
| .createEdit=${this.repoViewState.createEdit} | |
| ></gr-repo-commands> | |
| </div> | |
| `; | |
| } | |
| private renderRepoAccess() { | |
| if (this.view !== GerritView.REPO) return nothing; | |
| if (this.repoViewState?.detail !== RepoDetailView.ACCESS) return nothing; | |
| return html` | |
| <div class="main breadcrumbs"> | |
| <gr-repo-access .repo=${this.repoViewState.repo}></gr-repo-access> | |
| </div> | |
| `; | |
| } | |
| private renderRepoDashboards() { | |
| if (this.view !== GerritView.REPO) return nothing; | |
| if (this.repoViewState?.detail !== RepoDetailView.DASHBOARDS) | |
| return nothing; | |
| return html` | |
| <div class="main table breadcrumbs"> | |
| <gr-repo-dashboards | |
| .repo=${this.repoViewState.repo} | |
| .params=${this.repoViewState} | |
| ></gr-repo-dashboards> | |
| </div> | |
| `; | |
| } | |
| private renderRepoSubmitRequirements() { | |
| if (this.view !== GerritView.REPO) return nothing; | |
| if (this.repoViewState?.detail !== RepoDetailView.SUBMIT_REQUIREMENTS) | |
| return nothing; | |
| return html` | |
| <div class="main table breadcrumbs"> | |
| <gr-repo-submit-requirements | |
| .repo=${this.repoViewState.repo} | |
| .params=${this.repoViewState} | |
| ></gr-repo-submit-requirements> | |
| </div> | |
| `; | |
| } | |
| private renderRepoLabels() { | |
| if (this.view !== GerritView.REPO) return nothing; | |
| if (this.repoViewState?.detail !== RepoDetailView.LABELS) return nothing; | |
| return html` | |
| <div class="main table breadcrumbs"> | |
| <gr-repo-labels | |
| .repo=${this.repoViewState.repo} | |
| .params=${this.repoViewState} | |
| ></gr-repo-labels> | |
| </div> | |
| `; | |
| } | |
| private renderServerInfo() { | |
| if (this.view !== GerritView.ADMIN) return nothing; | |
| if (this.adminViewState?.adminView !== AdminChildView.SERVER_INFO) | |
| return nothing; | |
| return html` | |
| <div class="main table"> | |
| <gr-server-info class="table"></gr-server-info> | |
| </div> | |
| `; | |
| } | |
| override willUpdate(changedProperties: PropertyValues) { | |
| if (changedProperties.has('groupId')) { | |
| this.computeGroupName(); | |
| } | |
| } | |
| async reload() { | |
| try { | |
| this.reloading = true; | |
| // There is async barrier inside reload function, we need to clear | |
| // subsectionLinks now, because the element might render while waiting for | |
| // RestApi responses breaking the invariant that this.view is part of | |
| // subsectionLinks if non-empty. | |
| this.subsectionLinks = []; | |
| const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = | |
| [ | |
| this.restApiService.getAccount(), | |
| this.getPluginLoader().awaitPluginsLoaded(), | |
| ]; | |
| const result = await Promise.all(promises); | |
| const account = result[0]; | |
| if (account) { | |
| const capabilities = await this.restApiService.getAccountCapabilities([ | |
| 'deleteGroup', | |
| ]); | |
| this.canDeleteCapability = !!capabilities?.deleteGroup; | |
| } | |
| let options: AdminNavLinksOption | undefined = undefined; | |
| if (this.repoName) { | |
| options = {repoName: this.repoName}; | |
| } else if (this.groupId) { | |
| const isAdmin = await this.restApiService.getIsAdmin(); | |
| const isOwner = await this.restApiService.getIsGroupOwner( | |
| this.groupName | |
| ); | |
| options = { | |
| groupId: this.groupId, | |
| groupName: this.groupName, | |
| groupIsInternal: this.groupIsInternal, | |
| isAdmin, | |
| groupOwner: isOwner, | |
| }; | |
| } | |
| const res = await getAdminLinks( | |
| account, | |
| () => | |
| this.restApiService.getAccountCapabilities().then(capabilities => { | |
| if (!capabilities) { | |
| throw new Error('getAccountCapabilities returns undefined'); | |
| } | |
| return capabilities; | |
| }), | |
| () => this.getPluginLoader().jsApiService.getAdminMenuLinks(), | |
| options | |
| ); | |
| this.filteredLinks = res.links; | |
| this.breadcrumbParentName = res.expandedSection | |
| ? res.expandedSection.name | |
| : ''; | |
| if (!res.expandedSection) { | |
| return; | |
| } | |
| this.subsectionLinks = [res.expandedSection] | |
| .concat(res.expandedSection.children ?? []) | |
| .map(section => { | |
| return { | |
| text: !section.detailType ? 'Home' : section.name, | |
| value: section.view + (section.detailType ?? ''), | |
| view: section.view, | |
| url: section.url, | |
| detailType: section.detailType, | |
| parent: this.groupId ?? this.repoName, | |
| }; | |
| }); | |
| } finally { | |
| this.reloading = false; | |
| } | |
| } | |
| private getDetailView() { | |
| if (this.view === GerritView.REPO) return this.repoViewState?.detail; | |
| if (this.view === GerritView.GROUP) return this.groupViewState?.detail; | |
| return undefined; | |
| } | |
| private computeSelectValue() { | |
| return `${this.view}${this.getDetailView() ?? ''}`; | |
| } | |
| // private but used in test | |
| selectedIsCurrentPage(selected: AdminSubsectionLink) { | |
| if (!this.view) return false; | |
| return ( | |
| selected.parent === (this.repoName ?? this.groupId) && | |
| selected.view === this.view && | |
| selected.detailType === this.getDetailView() | |
| ); | |
| } | |
| // private but used in test | |
| handleSubsectionChange(e: ValueChangedEvent<string>) { | |
| if (!this.subsectionLinks) return; | |
| // The GrDropdownList items are subsectionLinks, so find(...) always return | |
| // an item subsectionLinks and never returns undefined | |
| const selected = this.subsectionLinks.find( | |
| section => section.value === e.detail.value | |
| )!; | |
| // This is when it gets set initially. | |
| if (this.selectedIsCurrentPage(selected)) return; | |
| if (selected.url === undefined) return; | |
| if (this.reloading) return; | |
| this.getNavigation().setUrl(selected.url); | |
| } | |
| isAdminView(): boolean { | |
| return ( | |
| this.view === GerritView.ADMIN || | |
| this.view === GerritView.GROUP || | |
| this.view === GerritView.REPO | |
| ); | |
| } | |
| needsReload(): boolean { | |
| if (!this.isAdminView()) return false; | |
| let needsReload = false; | |
| const newRepoName = | |
| this.view === GerritView.REPO ? this.repoViewState?.repo : undefined; | |
| if (newRepoName !== this.repoName) { | |
| this.repoName = newRepoName; | |
| // Reloads the admin menu. | |
| needsReload = true; | |
| } | |
| const newGroupId = | |
| this.view === GerritView.GROUP ? this.groupViewState?.groupId : undefined; | |
| if (newGroupId !== this.groupId) { | |
| this.groupId = newGroupId; | |
| // Reloads the admin menu. | |
| needsReload = true; | |
| } | |
| if ( | |
| this.breadcrumbParentName && | |
| (this.view !== GerritView.GROUP || !this.groupViewState?.groupId) && | |
| (this.view !== GerritView.REPO || !this.repoViewState?.repo) | |
| ) { | |
| needsReload = true; | |
| } | |
| return needsReload; | |
| } | |
| // private but used in test | |
| computeLinkURL(link?: NavLink | SubsectionInterface) { | |
| return link?.url || ''; | |
| } | |
| private computeSelectedClass( | |
| itemView?: GerritView | AdminChildView, | |
| detailType?: GroupDetailView | RepoDetailView | |
| ) { | |
| if (!this.view) return ''; | |
| // Group view state is structured differently than admin view state. Compute | |
| // selected differently for groups. | |
| // TODO(wyatta): Simplify this when all routes work like group view state. | |
| if (this.view === GerritView.GROUP && itemView === GerritView.GROUP) { | |
| if (!this.groupViewState?.detail && !detailType) { | |
| return 'selected'; | |
| } | |
| if (this.groupViewState?.detail === detailType) { | |
| return 'selected'; | |
| } | |
| return ''; | |
| } | |
| if (this.view === GerritView.REPO && itemView === GerritView.REPO) { | |
| if (!this.repoViewState?.detail && !detailType) { | |
| return 'selected'; | |
| } | |
| if (this.repoViewState?.detail === detailType) { | |
| return 'selected'; | |
| } | |
| return ''; | |
| } | |
| return this.view === GerritView.ADMIN && | |
| itemView === this.adminViewState?.adminView | |
| ? 'selected' | |
| : ''; | |
| } | |
| // private but used in test | |
| async computeGroupName() { | |
| if (!this.groupId) return; | |
| const group = await this.restApiService.getGroupConfig(this.groupId); | |
| if (!group || !group.name) { | |
| return; | |
| } | |
| this.groupName = group.name; | |
| this.groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX); | |
| await this.reload(); | |
| } | |
| private async updateGroupName(e: CustomEvent<GroupNameChangedDetail>) { | |
| this.groupName = e.detail.name; | |
| await this.reload(); | |
| } | |
| } | |
| declare global { | |
| interface HTMLElementTagNameMap { | |
| 'gr-admin-view': GrAdminView; | |
| } | |
| } |