| /** |
| * @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-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 {LitElement, PropertyValues, css, html, nothing} from 'lit'; |
| import {customElement, state} from 'lit/decorators.js'; |
| import {ifDefined} from 'lit/directives/if-defined.js'; |
| import {ValueChangedEvent} from '../../../types/events'; |
| import { |
| AdminChildView, |
| adminViewModelToken, |
| AdminViewState, |
| AdminNavLinksOption, |
| 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() 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.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} |
| @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} |
| ></gr-repo-dashboards> |
| </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]; |
| 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; |
| } |
| } |