blob: 58827fb68cd28a9075f586a6b2b8083f149fb055 [file] [log] [blame]
/**
* @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;
}
}