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