Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-header/mr-header.js b/static_src/elements/framework/mr-header/mr-header.js
new file mode 100644
index 0000000..6603c85
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.js
@@ -0,0 +1,427 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import 'elements/framework/mr-keystrokes/mr-keystrokes.js';
+import '../mr-dropdown/mr-dropdown.js';
+import '../mr-dropdown/mr-account-dropdown.js';
+import './mr-search-bar.js';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * @type {Object<string, string>} JS coding of enum values from
+ * appengine/monorail/api/v3/api_proto/project_objects.proto.
+ */
+const projectRoles = Object.freeze({
+ OWNER: 'Owner',
+ MEMBER: 'Member',
+ CONTRIBUTOR: 'Contributor',
+ NONE: '',
+});
+
+/**
+ * `<mr-header>`
+ *
+ * The header for Monorail.
+ *
+ */
+export class MrHeader extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ color: var(--chops-header-text-color);
+ box-sizing: border-box;
+ background: hsl(221, 67%, 92%);
+ width: 100%;
+ height: var(--monorail-header-height);
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: center;
+ z-index: 800;
+ background-color: var(--chops-primary-header-bg);
+ border-bottom: var(--chops-normal-border);
+ top: 0;
+ position: fixed;
+ padding: 0 4px;
+ font-size: var(--chops-large-font-size);
+ }
+ @media (max-width: 840px) {
+ :host {
+ position: static;
+ }
+ }
+ a {
+ font-size: inherit;
+ color: var(--chops-link-color);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 0 4px;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ a[hidden] {
+ display: none;
+ }
+ a.button {
+ font-size: inherit;
+ height: auto;
+ margin: 0 8px;
+ border: 0;
+ height: 30px;
+ }
+ .home-link {
+ color: var(--chops-gray-900);
+ letter-spacing: 0.5px;
+ font-size: 18px;
+ font-weight: 400;
+ display: flex;
+ font-stretch: 100%;
+ padding-left: 8px;
+ }
+ a.home-link img {
+ /** Cover up default padding with the custom logo. */
+ margin-left: -8px;
+ }
+ a.home-link:hover {
+ text-decoration: none;
+ }
+ mr-search-bar {
+ margin-left: 8px;
+ flex-grow: 2;
+ max-width: 1000px;
+ }
+ i.material-icons {
+ font-size: var(--chops-icon-font-size);
+ color: var(--chops-primary-icon-color);
+ }
+ i.material-icons[hidden] {
+ display: none;
+ }
+ .right-section {
+ font-size: inherit;
+ display: flex;
+ align-items: center;
+ height: 100%;
+ margin-left: auto;
+ justify-content: flex-end;
+ }
+ .hamburger-icon:hover {
+ text-decoration: none;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return this.projectName ?
+ this._renderProjectScope() : this._renderNonProjectScope();
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderProjectScope() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <mr-keystrokes
+ .issueId=${this.queryParams.id}
+ .queryParams=${this.queryParams}
+ .issueEntryUrl=${this.issueEntryUrl}
+ ></mr-keystrokes>
+ <a href="/p/${this.projectName}/issues/list" class="home-link">
+ ${this.projectThumbnailUrl ? html`
+ <img
+ class="project-logo"
+ src=${this.projectThumbnailUrl}
+ title=${this.projectName}
+ />
+ ` : this.projectName}
+ </a>
+ <mr-dropdown
+ class="project-selector"
+ .text=${this.projectName}
+ .items=${this._projectDropdownItems}
+ menuAlignment="left"
+ title=${this.presentationConfig.projectSummary}
+ ></mr-dropdown>
+ <a class="button emphasized new-issue-link" href=${this.issueEntryUrl}>
+ New issue
+ </a>
+ <mr-search-bar
+ .projectName=${this.projectName}
+ .userDisplayName=${this.userDisplayName}
+ .projectSavedQueries=${this.presentationConfig.savedQueries}
+ .initialCan=${this._currentCan}
+ .initialQuery=${this._currentQuery}
+ .queryParams=${this.queryParams}
+ ></mr-search-bar>
+
+ <div class="right-section">
+ <mr-dropdown
+ icon="settings"
+ label="Project Settings"
+ .items=${this._projectSettingsItems}
+ ></mr-dropdown>
+
+ ${this._renderAccount()}
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderNonProjectScope() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <a class="hamburger-icon" title="Main menu" hidden>
+ <i class="material-icons">menu</i>
+ </a>
+ ${this._headerTitle ?
+ html`<span class="home-link">${this._headerTitle}</span>` :
+ html`<a href="/" class="home-link">Monorail</a>`}
+
+ <div class="right-section">
+ ${this._renderAccount()}
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderAccount() {
+ if (!this.userDisplayName) {
+ return html`<a href=${this.loginUrl}>Sign in</a>`;
+ }
+
+ return html`
+ <mr-account-dropdown
+ .userDisplayName=${this.userDisplayName}
+ .logoutUrl=${this.logoutUrl}
+ .loginUrl=${this.loginUrl}
+ ></mr-account-dropdown>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ loginUrl: {type: String},
+ logoutUrl: {type: String},
+ projectName: {type: String},
+ // Project thumbnail is set separately from presentationConfig to prevent
+ // "flashing" logo when navigating EZT pages.
+ projectThumbnailUrl: {type: String},
+ userDisplayName: {type: String},
+ isSiteAdmin: {type: Boolean},
+ userProjects: {type: Object},
+ presentationConfig: {type: Object},
+ queryParams: {type: Object},
+ // TODO(zhangtiff): Change this to be dynamically computed by the
+ // frontend with logic similar to ComputeIssueEntryURL().
+ issueEntryUrl: {type: String},
+ clientLogger: {type: Object},
+ _headerTitle: {type: String},
+ _currentQuery: {type: String},
+ _currentCan: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.presentationConfig = {};
+ this.userProjects = {};
+ this.isSiteAdmin = false;
+
+ this._headerTitle = '';
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.projectName = projectV0.viewedProjectName(state);
+
+ this.userProjects = userV0.projects(state);
+
+ const currentUser = userV0.currentUser(state);
+ this.isSiteAdmin = currentUser ? currentUser.isSiteAdmin : false;
+
+ const presentationConfig = projectV0.viewedPresentationConfig(state);
+ this.presentationConfig = presentationConfig;
+ // Set separately in order allow EZT pages to load project logo before
+ // the GetPresentationConfig pRPC request.
+ this.projectThumbnailUrl = presentationConfig.projectThumbnailUrl;
+
+ this._headerTitle = sitewide.headerTitle(state);
+
+ this._currentQuery = sitewide.currentQuery(state);
+ this._currentCan = sitewide.currentCan(state);
+
+ this.queryParams = sitewide.queryParams(state);
+ }
+
+ /**
+ * @return {boolean} whether the currently logged in user has admin
+ * privileges for the currently viewed project.
+ */
+ get canAdministerProject() {
+ if (!this.userDisplayName) return false; // Not logged in.
+ if (this.isSiteAdmin) return true;
+ if (!this.userProjects || !this.userProjects.ownerOf) return false;
+ return this.userProjects.ownerOf.includes(this.projectName);
+ }
+
+ /**
+ * @return {string} The name of the role the user has in the viewed project.
+ */
+ get roleInCurrentProject() {
+ if (!this.userProjects || !this.projectName) return projectRoles.NONE;
+ const {ownerOf = [], memberOf = [], contributorTo = []} = this.userProjects;
+
+ if (ownerOf.includes(this.projectName)) return projectRoles.OWNER;
+ if (memberOf.includes(this.projectName)) return projectRoles.MEMBER;
+ if (contributorTo.includes(this.projectName)) {
+ return projectRoles.CONTRIBUTOR;
+ }
+
+ return projectRoles.NONE;
+ }
+
+ // TODO(crbug.com/monorail/6891): Remove once we deprecate the old issue
+ // filing wizard.
+ /**
+ * @return {string} A URL for the page the issue filing wizard posts to.
+ */
+ get _wizardPostUrl() {
+ // The issue filing wizard posts to the legacy issue entry page's ".do"
+ // endpoint.
+ return `${this._origin}/p/${this.projectName}/issues/entry.do`;
+ }
+
+ /**
+ * @return {string} The domain name of the current page.
+ */
+ get _origin() {
+ return window.location.origin;
+ }
+
+ /**
+ * Computes the URL the user should see to a file an issue, accounting
+ * for the case where a project has a customIssueEntryUrl to navigate to
+ * the wizard as well.
+ * @return {string} The URL that "New issue" button goes to.
+ */
+ get issueEntryUrl() {
+ const config = this.presentationConfig;
+ const role = this.roleInCurrentProject;
+ const mayBeRedirectedToWizard = role === projectRoles.NONE;
+ if (!this.userDisplayName || !config || !config.customIssueEntryUrl ||
+ !mayBeRedirectedToWizard) {
+ return `/p/${this.projectName}/issues/entry`;
+ }
+
+ const token = prpcClient.token;
+
+ const customUrl = this.presentationConfig.customIssueEntryUrl;
+
+ return `${customUrl}?token=${token}&role=${
+ role}&continue=${this._wizardPostUrl}`;
+ }
+
+ /**
+ * @return {Array<MenuItem>} the dropdown items for the project selector,
+ * showing which projects a user can switch to.
+ */
+ get _projectDropdownItems() {
+ const {userProjects, loginUrl} = this;
+ if (!this.userDisplayName) {
+ return [{text: 'Sign in to see your projects', url: loginUrl}];
+ }
+
+ const items = [];
+ const starredProjects = userProjects.starredProjects || [];
+ const projects = (userProjects.ownerOf || [])
+ .concat(userProjects.memberOf || [])
+ .concat(userProjects.contributorTo || []);
+
+ if (projects.length) {
+ projects.sort();
+ items.push({text: 'My Projects', separator: true});
+
+ projects.forEach((project) => {
+ items.push({text: project, url: `/p/${project}/issues/list`});
+ });
+ }
+
+ if (starredProjects.length) {
+ starredProjects.sort();
+ items.push({text: 'Starred Projects', separator: true});
+
+ starredProjects.forEach((project) => {
+ items.push({text: project, url: `/p/${project}/issues/list`});
+ });
+ }
+
+ if (items.length) {
+ items.push({separator: true});
+ }
+
+ items.push({text: 'All projects', url: '/hosting/'});
+ items.forEach((item) => {
+ item.handler = () => this._projectChangedHandler(item.url);
+ });
+ return items;
+ }
+
+ /**
+ * @return {Array<MenuItem>} dropdown menu items to show in the project
+ * settings menu.
+ */
+ get _projectSettingsItems() {
+ const {projectName, canAdministerProject} = this;
+ const items = [
+ {text: 'People', url: `/p/${projectName}/people/list`},
+ {text: 'Development Process', url: `/p/${projectName}/adminIntro`},
+ {text: 'History', url: `/p/${projectName}/updates/list`},
+ ];
+
+ if (canAdministerProject) {
+ items.push({separator: true});
+ items.push({text: 'Administer', url: `/p/${projectName}/admin`});
+ }
+ return items;
+ }
+
+ /**
+ * Records Google Analytics events for when users change projects using
+ * the selector.
+ * @param {string} url which project URL the user is navigating to.
+ */
+ _projectChangedHandler(url) {
+ // Just log it to GA and continue.
+ logEvent('mr-header', 'project-change', url);
+ }
+}
+
+customElements.define('mr-header', MrHeader);