blob: 558245e02b5f934609a6ebb0caa1d6c3c5c084a3 [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 'elements/issue-detail/mr-flipper/mr-flipper.js';
8import 'elements/chops/chops-dialog/chops-dialog.js';
9import 'elements/chops/chops-timestamp/chops-timestamp.js';
10import {store, connectStore} from 'reducers/base.js';
11import * as issueV0 from 'reducers/issueV0.js';
12import * as userV0 from 'reducers/userV0.js';
13import * as projectV0 from 'reducers/projectV0.js';
14import {userIsMember} from 'shared/helpers.js';
15import {SHARED_STYLES} from 'shared/shared-styles.js';
16import 'elements/framework/links/mr-user-link/mr-user-link.js';
17import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
18import 'elements/framework/mr-pref-toggle/mr-pref-toggle.js';
19import 'elements/framework/mr-dropdown/mr-dropdown.js';
20import {ISSUE_EDIT_PERMISSION, ISSUE_DELETE_PERMISSION,
21 ISSUE_FLAGSPAM_PERMISSION} from 'shared/consts/permissions.js';
22import {issueToIssueRef} from 'shared/convertersV0.js';
23import {prpcClient} from 'prpc-client-instance.js';
Copybara854996b2021-09-07 19:36:02 +000024
25const DELETE_ISSUE_CONFIRMATION_NOTICE = `\
26Normally, you would just close issues by setting their status to a closed value.
27Are you sure you want to delete this issue?`;
28
29
30/**
31 * `<mr-issue-header>`
32 *
33 * The header for a given launch issue.
34 *
35 */
36export class MrIssueHeader extends connectStore(LitElement) {
37 /** @override */
38 static get styles() {
39 return [
40 SHARED_STYLES,
41 css`
42 :host {
43 width: 100%;
44 margin-top: 0;
45 font-size: var(--chops-large-font-size);
46 background-color: var(--monorail-metadata-toggled-bg);
47 border-bottom: var(--chops-normal-border);
48 padding: 0.25em 8px;
49 box-sizing: border-box;
50 display: flex;
51 flex-direction: row;
52 justify-content: space-between;
53 align-items: center;
54 }
55 h1 {
56 font-size: 100%;
57 line-height: 140%;
58 font-weight: bolder;
59 padding: 0;
60 margin: 0;
61 }
62 mr-flipper {
63 border-left: var(--chops-normal-border);
64 padding-left: 8px;
65 margin-left: 4px;
66 font-size: var(--chops-main-font-size);
67 }
68 mr-pref-toggle {
69 margin-right: 2px;
70 }
71 .issue-actions {
72 min-width: fit-content;
73 display: flex;
74 flex-direction: row;
75 align-items: center;
76 font-size: var(--chops-main-font-size);
77 }
78 .issue-actions div {
79 min-width: 70px;
80 display: flex;
81 justify-content: space-between;
82 }
83 .spam-notice {
84 display: inline-flex;
85 align-items: center;
86 justify-content: center;
87 padding: 1px 6px;
88 border-radius: 3px;
89 background: #F44336;
90 color: var(--chops-white);
91 font-weight: bold;
92 font-size: var(--chops-main-font-size);
93 margin-right: 4px;
94 }
95 .byline {
96 display: block;
97 font-size: var(--chops-main-font-size);
98 width: 100%;
99 line-height: 140%;
100 color: var(--chops-primary-font-color);
101 }
102 .role-label {
103 background-color: var(--chops-gray-600);
104 border-radius: 3px;
105 color: var(--chops-white);
106 display: inline-block;
107 padding: 2px 4px;
108 font-size: 75%;
109 font-weight: bold;
110 line-height: 14px;
111 vertical-align: text-bottom;
112 margin-left: 16px;
113 }
114 .main-text-outer {
115 flex-basis: 100%;
116 display: flex;
117 justify-content: flex-start;
118 flex-direction: row;
119 align-items: center;
120 }
121 .main-text {
122 flex-basis: 100%;
123 }
124 @media (max-width: 840px) {
125 :host {
126 flex-wrap: wrap;
127 justify-content: center;
128 }
129 .main-text {
130 width: 100%;
131 margin-bottom: 0.5em;
132 }
133 }
134 `,
135 ];
136 }
137
138 /** @override */
139 render() {
140 const reporterIsMember = userIsMember(
141 this.issue.reporterRef, this.issue.projectName, this.usersProjects);
Adrià Vilanova Martínezd5550d42022-01-13 13:34:38 +0100142 const markdownEnabled = true;
143 const markdownDefaultOn = true;
Copybara854996b2021-09-07 19:36:02 +0000144 return html`
145 <div class="main-text-outer">
146 <div class="main-text">
147 <h1>
148 ${this.issue.isSpam ? html`
149 <span class="spam-notice">Spam</span>
150 `: ''}
151 Issue ${this.issue.localId}: ${this.issue.summary}
152 </h1>
153 <small class="byline">
154 Reported by
155 <mr-user-link
156 .userRef=${this.issue.reporterRef}
157 aria-label="issue reporter"
158 ></mr-user-link>
159 on <chops-timestamp .timestamp=${this.issue.openedTimestamp}></chops-timestamp>
160 ${reporterIsMember ? html`
161 <span class="role-label">Project Member</span>` : ''}
162 </small>
163 </div>
164 </div>
165 <div class="issue-actions">
166 <div>
167 <mr-crbug-link .issue=${this.issue}></mr-crbug-link>
168 <mr-pref-toggle
169 .userDisplayName=${this.userDisplayName}
170 label="Code"
171 title="Code font"
172 prefName="code_font"
173 ></mr-pref-toggle>
174 ${markdownEnabled ? html`
175 <mr-pref-toggle
176 .userDisplayName=${this.userDisplayName}
177 initialValue=${markdownDefaultOn}
178 label="Markdown"
179 title="Render in markdown"
180 prefName="render_markdown"
181 ></mr-pref-toggle> ` : ''}
182 </div>
183 ${this._issueOptions.length ? html`
184 <mr-dropdown
185 .items=${this._issueOptions}
186 icon="more_vert"
187 label="Issue options"
188 ></mr-dropdown>
189 ` : ''}
190 <mr-flipper></mr-flipper>
191 </div>
192 `;
193 }
194
195 /** @override */
196 static get properties() {
197 return {
198 userDisplayName: {type: String},
199 issue: {type: Object},
200 issuePermissions: {type: Object},
201 isRestricted: {type: Boolean},
202 projectTemplates: {type: Array},
203 projectName: {type: String},
204 usersProjects: {type: Object},
205 _action: {type: String},
206 _targetProjectError: {type: String},
207 };
208 }
209
210 /** @override */
211 constructor() {
212 super();
213 this.issuePermissions = [];
214 this.projectTemplates = [];
215 this.projectName = '';
216 this.issue = {};
217 this.usersProjects = new Map();
218 this.isRestricted = false;
219 }
220
221 /** @override */
222 stateChanged(state) {
223 this.issue = issueV0.viewedIssue(state);
224 this.issuePermissions = issueV0.permissions(state);
225 this.projectTemplates = projectV0.viewedTemplates(state);
226 this.projectName = projectV0.viewedProjectName(state);
227 this.usersProjects = userV0.projectsPerUser(state);
228
229 const restrictions = issueV0.restrictions(state);
230 this.isRestricted = restrictions && Object.keys(restrictions).length;
231 }
232
233 /**
234 * @return {Array<MenuItem>} Actions the user can take on the issue.
235 * @private
236 */
237 get _issueOptions() {
238 // We create two edit Arrays for the top and bottom half of the menu,
239 // to be separated by a separator in the UI.
240 const editOptions = [];
241 const riskyOptions = [];
242 const isSpam = this.issue.isSpam;
243 const isRestricted = this.isRestricted;
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100244 // Projects that allow some restricted issues to move.
245 // Context: https://crbug.com/monorail/11894
246 const projectsAllowedToMove = ['chromium', 'webrtc'];
Copybara854996b2021-09-07 19:36:02 +0000247
248 const permissions = this.issuePermissions;
249 const templates = this.projectTemplates;
250
251
252 if (permissions.includes(ISSUE_EDIT_PERMISSION)) {
253 editOptions.push({
254 text: 'Edit issue description',
255 handler: this._openEditDescription.bind(this),
256 });
257 if (templates.length) {
258 riskyOptions.push({
259 text: 'Convert issue template',
260 handler: this._openConvertIssue.bind(this),
261 });
262 }
263 }
264
265 if (permissions.includes(ISSUE_DELETE_PERMISSION)) {
266 riskyOptions.push({
267 text: 'Delete issue',
268 handler: this._deleteIssue.bind(this),
269 });
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100270 if (!isRestricted ||
271 projectsAllowedToMove.includes(this.projectName.toLowerCase())) {
Copybara854996b2021-09-07 19:36:02 +0000272 editOptions.push({
273 text: 'Move issue',
274 handler: this._openMoveCopyIssue.bind(this, 'Move'),
275 });
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100276 }
277 if (!isRestricted) {
Copybara854996b2021-09-07 19:36:02 +0000278 editOptions.push({
279 text: 'Copy issue',
280 handler: this._openMoveCopyIssue.bind(this, 'Copy'),
281 });
282 }
283 }
284
285 if (permissions.includes(ISSUE_FLAGSPAM_PERMISSION)) {
286 const text = (isSpam ? 'Un-flag' : 'Flag') + ' issue as spam';
287 riskyOptions.push({
288 text,
289 handler: this._markIssue.bind(this),
290 });
291 }
292
293 if (editOptions.length && riskyOptions.length) {
294 editOptions.push({separator: true});
295 }
296 return editOptions.concat(riskyOptions);
297 }
298
299 /**
300 * Marks an issue as either spam or not spam based on whether the issue
301 * was spam.
302 */
303 _markIssue() {
304 prpcClient.call('monorail.Issues', 'FlagIssues', {
305 issueRefs: [{
306 projectName: this.issue.projectName,
307 localId: this.issue.localId,
308 }],
309 flag: !this.issue.isSpam,
310 }).then(() => {
311 store.dispatch(issueV0.fetch({
312 projectName: this.issue.projectName,
313 localId: this.issue.localId,
314 }));
315 });
316 }
317
318 /**
319 * Deletes an issue.
320 */
321 _deleteIssue() {
322 const ok = confirm(DELETE_ISSUE_CONFIRMATION_NOTICE);
323 if (ok) {
324 const issueRef = issueToIssueRef(this.issue);
325 // TODO(crbug.com/monorail/7374): Delete for the v0 -> v3 migration.
326 prpcClient.call('monorail.Issues', 'DeleteIssue', {
327 issueRef,
328 delete: true,
329 }).then(() => {
330 store.dispatch(issueV0.fetch(issueRef));
331 });
332 }
333 }
334
335 /**
336 * Launches the dialog to edit an issue's description.
337 * @fires CustomEvent#open-dialog
338 * @private
339 */
340 _openEditDescription() {
341 this.dispatchEvent(new CustomEvent('open-dialog', {
342 bubbles: true,
343 composed: true,
344 detail: {
345 dialogId: 'edit-description',
346 fieldName: '',
347 },
348 }));
349 }
350
351 /**
352 * Opens dialog to either move or copy an issue.
353 * @param {"move"|"copy"} action
354 * @fires CustomEvent#open-dialog
355 * @private
356 */
357 _openMoveCopyIssue(action) {
358 this.dispatchEvent(new CustomEvent('open-dialog', {
359 bubbles: true,
360 composed: true,
361 detail: {
362 dialogId: 'move-copy-issue',
363 action,
364 },
365 }));
366 }
367
368 /**
369 * Opens dialog for converting an issue.
370 * @fires CustomEvent#open-dialog
371 * @private
372 */
373 _openConvertIssue() {
374 this.dispatchEvent(new CustomEvent('open-dialog', {
375 bubbles: true,
376 composed: true,
377 detail: {
378 dialogId: 'convert-issue',
379 },
380 }));
381 }
382}
383
384customElements.define('mr-issue-header', MrIssueHeader);