blob: a2b2ef1709fa16d8ca189b4d4f5384a762bc3813 [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 '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;
244
245 const permissions = this.issuePermissions;
246 const templates = this.projectTemplates;
247
248
249 if (permissions.includes(ISSUE_EDIT_PERMISSION)) {
250 editOptions.push({
251 text: 'Edit issue description',
252 handler: this._openEditDescription.bind(this),
253 });
254 if (templates.length) {
255 riskyOptions.push({
256 text: 'Convert issue template',
257 handler: this._openConvertIssue.bind(this),
258 });
259 }
260 }
261
262 if (permissions.includes(ISSUE_DELETE_PERMISSION)) {
263 riskyOptions.push({
264 text: 'Delete issue',
265 handler: this._deleteIssue.bind(this),
266 });
267 if (!isRestricted) {
268 editOptions.push({
269 text: 'Move issue',
270 handler: this._openMoveCopyIssue.bind(this, 'Move'),
271 });
272 editOptions.push({
273 text: 'Copy issue',
274 handler: this._openMoveCopyIssue.bind(this, 'Copy'),
275 });
276 }
277 }
278
279 if (permissions.includes(ISSUE_FLAGSPAM_PERMISSION)) {
280 const text = (isSpam ? 'Un-flag' : 'Flag') + ' issue as spam';
281 riskyOptions.push({
282 text,
283 handler: this._markIssue.bind(this),
284 });
285 }
286
287 if (editOptions.length && riskyOptions.length) {
288 editOptions.push({separator: true});
289 }
290 return editOptions.concat(riskyOptions);
291 }
292
293 /**
294 * Marks an issue as either spam or not spam based on whether the issue
295 * was spam.
296 */
297 _markIssue() {
298 prpcClient.call('monorail.Issues', 'FlagIssues', {
299 issueRefs: [{
300 projectName: this.issue.projectName,
301 localId: this.issue.localId,
302 }],
303 flag: !this.issue.isSpam,
304 }).then(() => {
305 store.dispatch(issueV0.fetch({
306 projectName: this.issue.projectName,
307 localId: this.issue.localId,
308 }));
309 });
310 }
311
312 /**
313 * Deletes an issue.
314 */
315 _deleteIssue() {
316 const ok = confirm(DELETE_ISSUE_CONFIRMATION_NOTICE);
317 if (ok) {
318 const issueRef = issueToIssueRef(this.issue);
319 // TODO(crbug.com/monorail/7374): Delete for the v0 -> v3 migration.
320 prpcClient.call('monorail.Issues', 'DeleteIssue', {
321 issueRef,
322 delete: true,
323 }).then(() => {
324 store.dispatch(issueV0.fetch(issueRef));
325 });
326 }
327 }
328
329 /**
330 * Launches the dialog to edit an issue's description.
331 * @fires CustomEvent#open-dialog
332 * @private
333 */
334 _openEditDescription() {
335 this.dispatchEvent(new CustomEvent('open-dialog', {
336 bubbles: true,
337 composed: true,
338 detail: {
339 dialogId: 'edit-description',
340 fieldName: '',
341 },
342 }));
343 }
344
345 /**
346 * Opens dialog to either move or copy an issue.
347 * @param {"move"|"copy"} action
348 * @fires CustomEvent#open-dialog
349 * @private
350 */
351 _openMoveCopyIssue(action) {
352 this.dispatchEvent(new CustomEvent('open-dialog', {
353 bubbles: true,
354 composed: true,
355 detail: {
356 dialogId: 'move-copy-issue',
357 action,
358 },
359 }));
360 }
361
362 /**
363 * Opens dialog for converting an issue.
364 * @fires CustomEvent#open-dialog
365 * @private
366 */
367 _openConvertIssue() {
368 this.dispatchEvent(new CustomEvent('open-dialog', {
369 bubbles: true,
370 composed: true,
371 detail: {
372 dialogId: 'convert-issue',
373 },
374 }));
375 }
376}
377
378customElements.define('mr-issue-header', MrIssueHeader);