blob: baa17cafb051953cb785a2b54e8b01eef940928b [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Subscription} from 'rxjs';
import {map, distinctUntilChanged} from 'rxjs/operators';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-icon/gr-icon';
import '../gr-account-dropdown/gr-account-dropdown';
import '../gr-smart-search/gr-smart-search';
import {getBaseUrl} from '../../../utils/url-util';
import {getAdminLinks, NavLink} from '../../../models/views/admin';
import {
AccountDetailInfo,
DropdownLink,
RequireProperties,
ServerInfo,
TopMenuEntryInfo,
TopMenuItemInfo,
} from '../../../types/common';
import {AuthType} from '../../../constants/constants';
import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {fire} from '../../../utils/event-util';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {userModelToken} from '../../../models/user/user-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
interface MainHeaderLinkGroup {
title: string;
links: MainHeaderLink[];
class?: string;
}
const DEFAULT_LINKS: MainHeaderLinkGroup[] = [
{
title: 'Changes',
links: [
{
url: '/q/status:open+-is:wip',
name: 'Open',
},
{
url: '/q/status:merged',
name: 'Merged',
},
{
url: '/q/status:abandoned',
name: 'Abandoned',
},
],
},
];
const DOCUMENTATION_LINKS: MainHeaderLink[] = [
{
url: '/index.html',
name: 'Table of Contents',
},
{
url: '/user-search.html',
name: 'Searching',
},
{
url: '/user-upload.html',
name: 'Uploading',
},
{
url: '/access-control.html',
name: 'Access Control',
},
{
url: '/rest-api.html',
name: 'REST API',
},
{
url: '/intro-project-owner.html',
name: 'Project Owner Guide',
},
];
// Set of authentication methods that can provide custom registration page.
const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
AuthType.LDAP,
AuthType.LDAP_BIND,
AuthType.CUSTOM_EXTENSION,
]);
declare global {
interface HTMLElementTagNameMap {
'gr-main-header': GrMainHeader;
}
interface HTMLElementEventMap {
'mobile-search': CustomEvent<{}>;
}
}
@customElement('gr-main-header')
export class GrMainHeader extends LitElement {
@property({type: Boolean, reflect: true})
loggedIn?: boolean;
@property({type: Boolean, reflect: true})
loading?: boolean;
@property({type: String})
loginUrl = '/login';
@property({type: String})
loginText = 'Sign in';
@property({type: Boolean})
mobileSearchHidden = false;
// private but used in test
@state() account?: AccountDetailInfo;
@state() private adminLinks: NavLink[] = [];
@state() private docBaseUrl: string | null = null;
@state() private userLinks: MainHeaderLink[] = [];
@state() private topMenus?: TopMenuEntryInfo[] = [];
// private but used in test
@state() registerText = 'Sign up';
// Empty string means that the register <div> will be hidden.
// private but used in test
@state() registerURL = '';
// private but used in test
@state() feedbackURL = '';
private readonly restApiService = getAppContext().restApiService;
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
private readonly getUserModel = resolve(this, userModelToken);
private readonly getConfigModel = resolve(this, configModelToken);
private subscriptions: Subscription[] = [];
override connectedCallback() {
super.connectedCallback();
this.loadAccount();
this.subscriptions.push(
this.getUserModel()
.preferences$.pipe(
map(preferences => preferences?.my ?? []),
distinctUntilChanged()
)
.subscribe(items => {
this.userLinks = items.map(this.createHeaderLink);
})
);
this.subscriptions.push(
this.getConfigModel().serverConfig$.subscribe(config => {
if (!config) return;
this.retrieveFeedbackURL(config);
this.retrieveRegisterURL(config);
this.restApiService.getDocsBaseUrl(config).then(docBaseUrl => {
this.docBaseUrl = docBaseUrl;
});
})
);
}
override disconnectedCallback() {
for (const s of this.subscriptions) {
s.unsubscribe();
}
this.subscriptions = [];
super.disconnectedCallback();
}
static override get styles() {
return [
sharedStyles,
css`
:host {
display: block;
}
nav {
align-items: center;
display: flex;
}
.bigTitle {
color: var(--header-text-color);
font-size: var(--header-title-font-size);
line-height: calc(var(--header-title-font-size) * 1.2);
text-decoration: none;
}
.bigTitle:hover {
text-decoration: underline;
}
.titleText {
/* Vertical alignment of icons and text with just block/inline display is too troublesome. */
display: flex;
align-items: center;
}
.titleText::before {
--icon-width: var(--header-icon-width, var(--header-icon-size, 0));
--icon-height: var(--header-icon-height, var(--header-icon-size, 0));
background-image: var(--header-icon);
background-size: var(--icon-width) var(--icon-height);
background-repeat: no-repeat;
content: '';
/* Any direct child of a flex element implicitly has 'display: block', but let's make that explicit here. */
display: block;
width: var(--icon-width);
height: var(--icon-height);
/* If size or height are set, then use 'spacing-m', 0px otherwise. */
margin-right: clamp(0px, var(--icon-height), var(--spacing-m));
}
.titleText::after {
/* The height will be determined by the line-height of the .bigTitle element. */
content: var(--header-title-content);
white-space: nowrap;
}
ul {
list-style: none;
padding-left: var(--spacing-l);
}
.links > li {
cursor: default;
display: inline-block;
padding: 0;
position: relative;
}
.linksTitle {
display: inline-block;
font-weight: var(--font-weight-bold);
position: relative;
text-transform: uppercase;
}
.linksTitle:hover {
opacity: 0.75;
}
.rightItems {
align-items: center;
display: flex;
flex: 1;
justify-content: flex-end;
}
.rightItems gr-endpoint-decorator:not(:empty) {
margin-left: var(--spacing-l);
}
gr-smart-search {
flex-grow: 1;
margin: 0 var(--spacing-m);
max-width: 500px;
min-width: 150px;
}
gr-dropdown,
.browse {
padding: var(--spacing-m);
}
gr-dropdown {
--gr-dropdown-item-color: var(--primary-text-color);
}
.settingsButton {
margin-left: var(--spacing-m);
}
.feedbackButton {
margin-left: var(--spacing-s);
}
.browse {
color: var(--header-text-color);
/* Same as gr-button */
margin: 5px 4px;
text-decoration: none;
}
.invisible,
.settingsButton,
gr-account-dropdown {
display: none;
}
:host([loading]) .accountContainer,
:host([loggedIn]) .loginButton,
:host([loggedIn]) .registerButton {
display: none;
}
:host([loggedIn]) .settingsButton,
:host([loggedIn]) gr-account-dropdown {
display: inline;
}
.accountContainer {
flex: 0 0 auto;
align-items: center;
display: flex;
margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loginButton,
.registerButton {
padding: var(--spacing-m) var(--spacing-l);
}
.dropdown-trigger {
text-decoration: none;
}
.dropdown-content {
background-color: var(--view-background-color);
box-shadow: var(--elevation-level-2);
}
/*
* We are not using :host to do this, because :host has a lowest css priority
* compared to others. This means that using :host to do this would break styles.
*/
.linksTitle,
.bigTitle,
.loginButton,
.registerButton,
gr-icon,
gr-dropdown,
gr-account-dropdown {
--gr-button-text-color: var(--header-text-color);
color: var(--header-text-color);
}
#mobileSearch {
display: none;
}
@media screen and (max-width: 50em) {
.bigTitle {
font-family: var(--header-font-family);
font-size: var(--font-size-h3);
font-weight: var(--font-weight-h3);
line-height: var(--line-height-h3);
}
gr-smart-search,
.browse,
.rightItems .hideOnMobile,
.links > li.hideOnMobile {
display: none;
}
#mobileSearch {
display: inline-flex;
}
.accountContainer {
margin-left: var(--spacing-m) !important;
}
gr-dropdown {
padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
}
}
`,
];
}
override render() {
return html`
<nav>
<a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
<gr-endpoint-decorator name="header-title">
<div class="titleText"></div>
</gr-endpoint-decorator>
</a>
<ul class="links">
${this.computeLinks(
this.userLinks,
this.adminLinks,
this.topMenus,
this.docBaseUrl
).map(linkGroup => this.renderLinkGroup(linkGroup))}
</ul>
<div class="rightItems">
<gr-endpoint-decorator
class="hideOnMobile"
name="header-small-banner"
></gr-endpoint-decorator>
<gr-smart-search id="search"></gr-smart-search>
<gr-endpoint-decorator
class="hideOnMobile"
name="header-top-right"
></gr-endpoint-decorator>
<gr-endpoint-decorator class="feedbackButton" name="header-feedback">
${this.renderFeedback()}
</gr-endpoint-decorator>
</div>
${this.renderAccount()}
</div>
</nav>
`;
}
private renderLinkGroup(linkGroup: MainHeaderLinkGroup) {
return html`
<li class=${linkGroup.class ?? ''}>
<gr-dropdown
link
down-arrow
.items=${linkGroup.links}
horizontal-align="left"
>
<span class="linksTitle" id=${linkGroup.title}>
${linkGroup.title}
</span>
</gr-dropdown>
</li>
`;
}
private renderFeedback() {
if (!this.feedbackURL) return;
return html`
<a
href=${this.feedbackURL}
title="File a bug"
aria-label="File a bug"
target="_blank"
role="button"
>
<gr-icon icon="bug_report" filled></gr-icon>
</a>
`;
}
private renderAccount() {
return html`
<div class="accountContainer" id="accountContainer">
<div>
<gr-icon
id="mobileSearch"
icon="search"
@click=${(e: Event) => {
this.onMobileSearchTap(e);
}}
role="button"
aria-label=${this.mobileSearchHidden
? 'Show Searchbar'
: 'Hide Searchbar'}
></gr-icon>
</div>
${this.renderRegister()}
<a class="loginButton" href=${this.loginUrl}>${this.loginText}</a>
<a
class="settingsButton"
href="${getBaseUrl()}/settings/"
title="Settings"
aria-label="Settings"
role="button"
>
<gr-icon icon="settings" filled></gr-icon>
</a>
${this.renderAccountDropdown()}
</div>
`;
}
private renderRegister() {
if (!this.registerURL) return;
return html`
<div class="registerDiv">
<a class="registerButton" href=${this.registerURL}>
${this.registerText}
</a>
</div>
`;
}
private renderAccountDropdown() {
if (!this.account) return;
return html`
<gr-account-dropdown .account=${this.account}></gr-account-dropdown>
`;
}
override firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (!this.getAttribute('role')) this.setAttribute('role', 'banner');
}
reload() {
this.loadAccount();
}
// private but used in test
computeLinks(
userLinks?: MainHeaderLink[],
adminLinks?: NavLink[],
topMenus?: TopMenuEntryInfo[],
docBaseUrl?: string | null,
// defaultLinks parameter is used in tests only
defaultLinks = DEFAULT_LINKS
) {
if (
userLinks === undefined ||
adminLinks === undefined ||
topMenus === undefined ||
docBaseUrl === undefined
) {
return [];
}
const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
return {
title: menu.title,
links: menu.links.slice(),
};
});
if (userLinks && userLinks.length > 0) {
links.push({
title: 'Your',
links: userLinks.slice(),
});
}
const docLinks = this.getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
if (docLinks.length) {
links.push({
title: 'Documentation',
links: docLinks,
class: 'hideOnMobile',
});
}
links.push({
title: 'Browse',
links: adminLinks.slice(),
});
const topMenuLinks: {[name: string]: MainHeaderLink[]} = {};
links.forEach(link => {
topMenuLinks[link.title] = link.links;
});
for (const m of topMenus) {
const items = m.items.map(this.createHeaderLink).filter(
link =>
// Ignore GWT project links
!link.url.includes('${projectName}')
);
if (m.name in topMenuLinks) {
items.forEach(link => {
topMenuLinks[m.name].push(link);
});
} else if (items.length > 0) {
links.push({
title: m.name,
links: (topMenuLinks[m.name] = items),
});
}
}
return links;
}
// private but used in test
getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
if (!docBaseUrl) {
return [];
}
return docLinks.map(link => {
let url = docBaseUrl;
if (url && url[url.length - 1] === '/') {
url = url.substring(0, url.length - 1);
}
return {
url: url + link.url,
name: link.name,
target: '_blank',
};
});
}
// private but used in test
loadAccount() {
this.loading = true;
return Promise.all([
this.restApiService.getAccount(),
this.restApiService.getTopMenus(),
this.getPluginLoader().awaitPluginsLoaded(),
]).then(result => {
const account = result[0];
this.account = account;
this.loggedIn = !!account;
this.loading = false;
this.topMenus = result[1];
return getAdminLinks(
account,
() =>
this.restApiService.getAccountCapabilities().then(capabilities => {
if (!capabilities) {
throw new Error('getAccountCapabilities returns undefined');
}
return capabilities;
}),
() => this.getPluginLoader().jsApiService.getAdminMenuLinks()
).then(res => {
this.adminLinks = res.links;
});
});
}
// private but used in test
retrieveFeedbackURL(config: ServerInfo) {
if (config.gerrit?.report_bug_url) {
this.feedbackURL = config.gerrit.report_bug_url;
}
}
// private but used in test
retrieveRegisterURL(config: ServerInfo) {
if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
this.registerURL = config.auth.register_url ?? '';
if (config.auth.register_text) {
this.registerText = config.auth.register_text;
}
}
}
// private but used in test
createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
// Delete target property due to complications of
// https://issues.gerritcodereview.com/issues/40006107
//
// The server tries to guess whether URL is a view within the UI.
// If not, it sets target='_blank' on the menu item. The server
// makes assumptions that work for the GWT UI, but not PolyGerrit,
// so we'll just disable it altogether for now.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {target, ...headerLink} = {...linkObj};
// Normalize all urls to PolyGerrit style.
if (headerLink.url.startsWith('#')) {
headerLink.url = linkObj.url.slice(1);
}
return headerLink;
}
private onMobileSearchTap(e: Event) {
e.preventDefault();
e.stopPropagation();
fire(this, 'mobile-search', {});
}
}