blob: 6603c8517d164406d6abd236a201bb15aabdf872 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6
7import {connectStore} from 'reducers/base.js';
8import * as userV0 from 'reducers/userV0.js';
9import * as projectV0 from 'reducers/projectV0.js';
10import * as sitewide from 'reducers/sitewide.js';
11
12import {prpcClient} from 'prpc-client-instance.js';
13import 'elements/framework/mr-keystrokes/mr-keystrokes.js';
14import '../mr-dropdown/mr-dropdown.js';
15import '../mr-dropdown/mr-account-dropdown.js';
16import './mr-search-bar.js';
17
18import {SHARED_STYLES} from 'shared/shared-styles.js';
19
20import {logEvent} from 'monitoring/client-logger.js';
21
22/**
23 * @type {Object<string, string>} JS coding of enum values from
24 * appengine/monorail/api/v3/api_proto/project_objects.proto.
25 */
26const projectRoles = Object.freeze({
27 OWNER: 'Owner',
28 MEMBER: 'Member',
29 CONTRIBUTOR: 'Contributor',
30 NONE: '',
31});
32
33/**
34 * `<mr-header>`
35 *
36 * The header for Monorail.
37 *
38 */
39export class MrHeader extends connectStore(LitElement) {
40 /** @override */
41 static get styles() {
42 return [
43 SHARED_STYLES,
44 css`
45 :host {
46 color: var(--chops-header-text-color);
47 box-sizing: border-box;
48 background: hsl(221, 67%, 92%);
49 width: 100%;
50 height: var(--monorail-header-height);
51 display: flex;
52 flex-direction: row;
53 justify-content: flex-start;
54 align-items: center;
55 z-index: 800;
56 background-color: var(--chops-primary-header-bg);
57 border-bottom: var(--chops-normal-border);
58 top: 0;
59 position: fixed;
60 padding: 0 4px;
61 font-size: var(--chops-large-font-size);
62 }
63 @media (max-width: 840px) {
64 :host {
65 position: static;
66 }
67 }
68 a {
69 font-size: inherit;
70 color: var(--chops-link-color);
71 text-decoration: none;
72 display: flex;
73 align-items: center;
74 justify-content: center;
75 height: 100%;
76 padding: 0 4px;
77 flex-grow: 0;
78 flex-shrink: 0;
79 }
80 a[hidden] {
81 display: none;
82 }
83 a.button {
84 font-size: inherit;
85 height: auto;
86 margin: 0 8px;
87 border: 0;
88 height: 30px;
89 }
90 .home-link {
91 color: var(--chops-gray-900);
92 letter-spacing: 0.5px;
93 font-size: 18px;
94 font-weight: 400;
95 display: flex;
96 font-stretch: 100%;
97 padding-left: 8px;
98 }
99 a.home-link img {
100 /** Cover up default padding with the custom logo. */
101 margin-left: -8px;
102 }
103 a.home-link:hover {
104 text-decoration: none;
105 }
106 mr-search-bar {
107 margin-left: 8px;
108 flex-grow: 2;
109 max-width: 1000px;
110 }
111 i.material-icons {
112 font-size: var(--chops-icon-font-size);
113 color: var(--chops-primary-icon-color);
114 }
115 i.material-icons[hidden] {
116 display: none;
117 }
118 .right-section {
119 font-size: inherit;
120 display: flex;
121 align-items: center;
122 height: 100%;
123 margin-left: auto;
124 justify-content: flex-end;
125 }
126 .hamburger-icon:hover {
127 text-decoration: none;
128 }
129 `,
130 ];
131 }
132
133 /** @override */
134 render() {
135 return this.projectName ?
136 this._renderProjectScope() : this._renderNonProjectScope();
137 }
138
139 /**
140 * @return {TemplateResult}
141 */
142 _renderProjectScope() {
143 return html`
144 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
145 <mr-keystrokes
146 .issueId=${this.queryParams.id}
147 .queryParams=${this.queryParams}
148 .issueEntryUrl=${this.issueEntryUrl}
149 ></mr-keystrokes>
150 <a href="/p/${this.projectName}/issues/list" class="home-link">
151 ${this.projectThumbnailUrl ? html`
152 <img
153 class="project-logo"
154 src=${this.projectThumbnailUrl}
155 title=${this.projectName}
156 />
157 ` : this.projectName}
158 </a>
159 <mr-dropdown
160 class="project-selector"
161 .text=${this.projectName}
162 .items=${this._projectDropdownItems}
163 menuAlignment="left"
164 title=${this.presentationConfig.projectSummary}
165 ></mr-dropdown>
166 <a class="button emphasized new-issue-link" href=${this.issueEntryUrl}>
167 New issue
168 </a>
169 <mr-search-bar
170 .projectName=${this.projectName}
171 .userDisplayName=${this.userDisplayName}
172 .projectSavedQueries=${this.presentationConfig.savedQueries}
173 .initialCan=${this._currentCan}
174 .initialQuery=${this._currentQuery}
175 .queryParams=${this.queryParams}
176 ></mr-search-bar>
177
178 <div class="right-section">
179 <mr-dropdown
180 icon="settings"
181 label="Project Settings"
182 .items=${this._projectSettingsItems}
183 ></mr-dropdown>
184
185 ${this._renderAccount()}
186 </div>
187 `;
188 }
189
190 /**
191 * @return {TemplateResult}
192 */
193 _renderNonProjectScope() {
194 return html`
195 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
196 <a class="hamburger-icon" title="Main menu" hidden>
197 <i class="material-icons">menu</i>
198 </a>
199 ${this._headerTitle ?
200 html`<span class="home-link">${this._headerTitle}</span>` :
201 html`<a href="/" class="home-link">Monorail</a>`}
202
203 <div class="right-section">
204 ${this._renderAccount()}
205 </div>
206 `;
207 }
208
209 /**
210 * @return {TemplateResult}
211 */
212 _renderAccount() {
213 if (!this.userDisplayName) {
214 return html`<a href=${this.loginUrl}>Sign in</a>`;
215 }
216
217 return html`
218 <mr-account-dropdown
219 .userDisplayName=${this.userDisplayName}
220 .logoutUrl=${this.logoutUrl}
221 .loginUrl=${this.loginUrl}
222 ></mr-account-dropdown>
223 `;
224 }
225
226 /** @override */
227 static get properties() {
228 return {
229 loginUrl: {type: String},
230 logoutUrl: {type: String},
231 projectName: {type: String},
232 // Project thumbnail is set separately from presentationConfig to prevent
233 // "flashing" logo when navigating EZT pages.
234 projectThumbnailUrl: {type: String},
235 userDisplayName: {type: String},
236 isSiteAdmin: {type: Boolean},
237 userProjects: {type: Object},
238 presentationConfig: {type: Object},
239 queryParams: {type: Object},
240 // TODO(zhangtiff): Change this to be dynamically computed by the
241 // frontend with logic similar to ComputeIssueEntryURL().
242 issueEntryUrl: {type: String},
243 clientLogger: {type: Object},
244 _headerTitle: {type: String},
245 _currentQuery: {type: String},
246 _currentCan: {type: String},
247 };
248 }
249
250 /** @override */
251 constructor() {
252 super();
253
254 this.presentationConfig = {};
255 this.userProjects = {};
256 this.isSiteAdmin = false;
257
258 this._headerTitle = '';
259 }
260
261 /** @override */
262 stateChanged(state) {
263 this.projectName = projectV0.viewedProjectName(state);
264
265 this.userProjects = userV0.projects(state);
266
267 const currentUser = userV0.currentUser(state);
268 this.isSiteAdmin = currentUser ? currentUser.isSiteAdmin : false;
269
270 const presentationConfig = projectV0.viewedPresentationConfig(state);
271 this.presentationConfig = presentationConfig;
272 // Set separately in order allow EZT pages to load project logo before
273 // the GetPresentationConfig pRPC request.
274 this.projectThumbnailUrl = presentationConfig.projectThumbnailUrl;
275
276 this._headerTitle = sitewide.headerTitle(state);
277
278 this._currentQuery = sitewide.currentQuery(state);
279 this._currentCan = sitewide.currentCan(state);
280
281 this.queryParams = sitewide.queryParams(state);
282 }
283
284 /**
285 * @return {boolean} whether the currently logged in user has admin
286 * privileges for the currently viewed project.
287 */
288 get canAdministerProject() {
289 if (!this.userDisplayName) return false; // Not logged in.
290 if (this.isSiteAdmin) return true;
291 if (!this.userProjects || !this.userProjects.ownerOf) return false;
292 return this.userProjects.ownerOf.includes(this.projectName);
293 }
294
295 /**
296 * @return {string} The name of the role the user has in the viewed project.
297 */
298 get roleInCurrentProject() {
299 if (!this.userProjects || !this.projectName) return projectRoles.NONE;
300 const {ownerOf = [], memberOf = [], contributorTo = []} = this.userProjects;
301
302 if (ownerOf.includes(this.projectName)) return projectRoles.OWNER;
303 if (memberOf.includes(this.projectName)) return projectRoles.MEMBER;
304 if (contributorTo.includes(this.projectName)) {
305 return projectRoles.CONTRIBUTOR;
306 }
307
308 return projectRoles.NONE;
309 }
310
311 // TODO(crbug.com/monorail/6891): Remove once we deprecate the old issue
312 // filing wizard.
313 /**
314 * @return {string} A URL for the page the issue filing wizard posts to.
315 */
316 get _wizardPostUrl() {
317 // The issue filing wizard posts to the legacy issue entry page's ".do"
318 // endpoint.
319 return `${this._origin}/p/${this.projectName}/issues/entry.do`;
320 }
321
322 /**
323 * @return {string} The domain name of the current page.
324 */
325 get _origin() {
326 return window.location.origin;
327 }
328
329 /**
330 * Computes the URL the user should see to a file an issue, accounting
331 * for the case where a project has a customIssueEntryUrl to navigate to
332 * the wizard as well.
333 * @return {string} The URL that "New issue" button goes to.
334 */
335 get issueEntryUrl() {
336 const config = this.presentationConfig;
337 const role = this.roleInCurrentProject;
338 const mayBeRedirectedToWizard = role === projectRoles.NONE;
339 if (!this.userDisplayName || !config || !config.customIssueEntryUrl ||
340 !mayBeRedirectedToWizard) {
341 return `/p/${this.projectName}/issues/entry`;
342 }
343
344 const token = prpcClient.token;
345
346 const customUrl = this.presentationConfig.customIssueEntryUrl;
347
348 return `${customUrl}?token=${token}&role=${
349 role}&continue=${this._wizardPostUrl}`;
350 }
351
352 /**
353 * @return {Array<MenuItem>} the dropdown items for the project selector,
354 * showing which projects a user can switch to.
355 */
356 get _projectDropdownItems() {
357 const {userProjects, loginUrl} = this;
358 if (!this.userDisplayName) {
359 return [{text: 'Sign in to see your projects', url: loginUrl}];
360 }
361
362 const items = [];
363 const starredProjects = userProjects.starredProjects || [];
364 const projects = (userProjects.ownerOf || [])
365 .concat(userProjects.memberOf || [])
366 .concat(userProjects.contributorTo || []);
367
368 if (projects.length) {
369 projects.sort();
370 items.push({text: 'My Projects', separator: true});
371
372 projects.forEach((project) => {
373 items.push({text: project, url: `/p/${project}/issues/list`});
374 });
375 }
376
377 if (starredProjects.length) {
378 starredProjects.sort();
379 items.push({text: 'Starred Projects', separator: true});
380
381 starredProjects.forEach((project) => {
382 items.push({text: project, url: `/p/${project}/issues/list`});
383 });
384 }
385
386 if (items.length) {
387 items.push({separator: true});
388 }
389
390 items.push({text: 'All projects', url: '/hosting/'});
391 items.forEach((item) => {
392 item.handler = () => this._projectChangedHandler(item.url);
393 });
394 return items;
395 }
396
397 /**
398 * @return {Array<MenuItem>} dropdown menu items to show in the project
399 * settings menu.
400 */
401 get _projectSettingsItems() {
402 const {projectName, canAdministerProject} = this;
403 const items = [
404 {text: 'People', url: `/p/${projectName}/people/list`},
405 {text: 'Development Process', url: `/p/${projectName}/adminIntro`},
406 {text: 'History', url: `/p/${projectName}/updates/list`},
407 ];
408
409 if (canAdministerProject) {
410 items.push({separator: true});
411 items.push({text: 'Administer', url: `/p/${projectName}/admin`});
412 }
413 return items;
414 }
415
416 /**
417 * Records Google Analytics events for when users change projects using
418 * the selector.
419 * @param {string} url which project URL the user is navigating to.
420 */
421 _projectChangedHandler(url) {
422 // Just log it to GA and continue.
423 logEvent('mr-header', 'project-change', url);
424 }
425}
426
427customElements.define('mr-header', MrHeader);