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