blob: 2a34b8feff90df8cf19926588b8624220d65e271 [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/framework/mr-upload/mr-upload.js';
8import 'elements/framework/mr-error/mr-error.js';
9import {fieldTypes} from 'shared/issue-fields.js';
10import {store, connectStore} from 'reducers/base.js';
11import * as issueV0 from 'reducers/issueV0.js';
12import * as projectV0 from 'reducers/projectV0.js';
13import * as userV0 from 'reducers/userV0.js';
14import 'elements/chops/chops-checkbox/chops-checkbox.js';
15import 'elements/chops/chops-dialog/chops-dialog.js';
16import {SHARED_STYLES, MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
17import {commentListToDescriptionList} from 'shared/convertersV0.js';
18import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
19import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
20
21
22/**
23 * `<mr-edit-description>`
24 *
25 * A dialog to edit descriptions.
26 *
27 */
28export class MrEditDescription extends connectStore(LitElement) {
29 /** @override */
30 constructor() {
31 super();
32 this._editedDescription = '';
33 }
34
35 /** @override */
36 static get styles() {
37 return [
38 SHARED_STYLES,
39 MD_PREVIEW_STYLES,
40 MD_STYLES,
41 css`
42 chops-dialog {
43 --chops-dialog-width: 800px;
44 }
45 textarea {
46 font-family: var(--mr-toggled-font-family);
47 min-height: 300px;
48 max-height: 500px;
49 border: var(--chops-accessible-border);
50 padding: 0.5em 4px;
51 margin: 0.5em 0;
52 }
53 .attachments {
54 margin: 0.5em 0;
55 }
56 .content {
57 padding: 0.5em 0.5em;
58 width: 100%;
59 box-sizing: border-box;
60 }
61 .edit-controls {
62 display: flex;
63 justify-content: space-between;
64 align-items: center;
65 }
66 `,
67 ];
68 }
69
70 /** @override */
71 render() {
72 return html`
73 <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
74 rel="stylesheet">
75 <chops-dialog aria-labelledby="editDialogTitle">
76 <h3 id="editDialogTitle" class="medium-heading">
77 Edit ${this._title}
78 </h3>
79 <textarea
80 id="description"
81 class="content"
82 @keyup=${this._setEditedDescription}
83 @change=${this._setEditedDescription}
84 .value=${this._editedDescription}
85 ></textarea>
86 ${this._renderMarkdown ? html`
87 <div class="markdown-preview preview-height-description">
88 <div class="markdown">
89 ${unsafeHTML(renderMarkdown(this._editedDescription))}
90 </div>
91 </div>`: ''}
92 <h3 class="medium-heading">
93 Add attachments
94 </h3>
95 <div class="attachments">
96 ${this._attachments && this._attachments.map((attachment) => html`
97 <label>
98 <chops-checkbox
99 type="checkbox"
100 checked="true"
101 class="kept-attachment"
102 data-attachment-id=${attachment.attachmentId}
103 @checked-change=${this._keptAttachmentIdsChanged}
104 />
105 <a href=${attachment.viewUrl} target="_blank">
106 ${attachment.filename}
107 </a>
108 </label>
109 <br>
110 `)}
111 <mr-upload></mr-upload>
112 </div>
113 <mr-error
114 ?hidden=${!this._attachmentError}
115 >${this._attachmentError}</mr-error>
116 <div class="edit-controls">
117 <chops-checkbox
118 id="sendEmail"
119 ?checked=${this._sendEmail}
120 @checked-change=${this._setSendEmail}
121 >Send email</chops-checkbox>
122 <div>
123 <chops-button id="discard" @click=${this.cancel} class="de-emphasized">
124 Discard
125 </chops-button>
126 <chops-button id="save" @click=${this.save} class="emphasized">
127 Save changes
128 </chops-button>
129 </div>
130 </div>
131 </chops-dialog>
132 `;
133 }
134
135 /** @override */
136 static get properties() {
137 return {
138 commentsByApproval: {type: Array},
139 issueRef: {type: Object},
140 fieldName: {type: String},
141 projectName: {type: String},
142 _attachmentError: {type: String},
143 _attachments: {type: Array},
144 _boldLines: {type: Array},
145 _editedDescription: {type: String},
146 _title: {type: String},
147 _keptAttachmentIds: {type: Object},
148 _sendEmail: {type: Boolean},
149 _prefs: {type: Object},
150 };
151 }
152
153 /** @override */
154 stateChanged(state) {
155 this.commentsByApproval = issueV0.commentsByApprovalName(state);
156 this.issueRef = issueV0.viewedIssueRef(state);
157 this.projectName = projectV0.viewedProjectName(state);
158 this._prefs = userV0.prefs(state);
159 }
160
161 /**
162 * Public function to open the issue description editing dialog.
163 * @param {Event} e
164 */
165 async open(e) {
166 await this.updateComplete;
167 this.shadowRoot.querySelector('chops-dialog').open();
168 this.fieldName = e.detail.fieldName;
169 this.reset();
170 }
171
172 /**
173 * Resets edit form.
174 */
175 async reset() {
176 await this.updateComplete;
177 this._attachmentError = '';
178 this._attachments = [];
179 this._boldLines = [];
180 this._keptAttachmentIds = new Set();
181
182 const uploader = this.shadowRoot.querySelector('mr-upload');
183 if (uploader) {
184 uploader.reset();
185 }
186
187 // Sets _editedDescription and _title.
188 this._initializeView(this.commentsByApproval, this.fieldName);
189
190 this.shadowRoot.querySelectorAll('.kept-attachment').forEach((checkbox) => {
191 checkbox.checked = true;
192 });
193 this.shadowRoot.querySelector('#sendEmail').checked = true;
194
195 this._sendEmail = true;
196 }
197
198 /**
199 * Cancels in-flight edit data.
200 */
201 async cancel() {
202 await this.updateComplete;
203 this.shadowRoot.querySelector('chops-dialog').close();
204 }
205
206 /**
207 * Sends the user's edit to Monorail's backend to be saved.
208 */
209 async save() {
210 const commentContent = this._markupNewContent();
211 const sendEmail = this._sendEmail;
212 const keptAttachments = Array.from(this._keptAttachmentIds);
213 const message = {
214 issueRef: this.issueRef,
215 isDescription: true,
216 commentContent,
217 keptAttachments,
218 sendEmail,
219 };
220
221 try {
222 const uploader = this.shadowRoot.querySelector('mr-upload');
223 const uploads = await uploader.loadFiles();
224 if (uploads && uploads.length) {
225 message.uploads = uploads;
226 }
227
228 if (!this.fieldName) {
229 store.dispatch(issueV0.update(message));
230 } else {
231 // This is editing an approval if there is no field name.
232 message.fieldRef = {
233 type: fieldTypes.APPROVAL_TYPE,
234 fieldName: this.fieldName,
235 };
236 store.dispatch(issueV0.updateApproval(message));
237 }
238 this.shadowRoot.querySelector('chops-dialog').close();
239 } catch (e) {
240 this._attachmentError = `Error while loading file for attachment: ${
241 e.message}`;
242 }
243 }
244
245 /**
246 * Getter for checking if the user has Markdown enabled.
247 * @return {boolean} Whether Markdown preview should be rendered or not.
248 */
249 get _renderMarkdown() {
250 const enabled = this._prefs.get('render_markdown');
251 return shouldRenderMarkdown({project: this.projectName, enabled});
252 }
253
254 /**
255 * Event handler for keeping <mr-edit-description>'s copy of
256 * _editedDescription in sync.
257 * @param {Event} e
258 */
259 _setEditedDescription(e) {
260 const target = e.target;
261 this._editedDescription = target.value;
262 }
263
264 /**
265 * Event handler for keeping attachment state in sync.
266 * @param {Event} e
267 */
268 _keptAttachmentIdsChanged(e) {
269 e.target.checked = e.detail.checked;
270 const attachmentId = Number.parseInt(e.target.dataset.attachmentId);
271 if (e.target.checked) {
272 this._keptAttachmentIds.add(attachmentId);
273 } else {
274 this._keptAttachmentIds.delete(attachmentId);
275 }
276 }
277
278 _initializeView(commentsByApproval, fieldName) {
279 this._title = fieldName ? `${fieldName} Survey` : 'Description';
280 const key = fieldName || '';
281 if (!commentsByApproval || !commentsByApproval.has(key)) return;
282 const comments = commentListToDescriptionList(commentsByApproval.get(key));
283
284 const comment = comments[comments.length - 1];
285
286 if (comment.attachments) {
287 this._keptAttachmentIds = new Set(comment.attachments.map(
288 (attachment) => Number.parseInt(attachment.attachmentId)));
289 this._attachments = comment.attachments;
290 }
291
292 this._processRawContent(comment.content);
293 }
294
295 _processRawContent(content) {
296 const chunks = content.trim().split(/(<b>[^<\n]+<\/b>)/m);
297 const boldLines = [];
298 let cleanContent = '';
299 chunks.forEach((chunk) => {
300 if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
301 const cleanChunk = chunk.slice(3, -4).trim();
302 cleanContent += cleanChunk;
303 // Don't add whitespace to boldLines.
304 if (/\S/.test(cleanChunk)) {
305 boldLines.push(cleanChunk);
306 }
307 } else {
308 cleanContent += chunk;
309 }
310 });
311
312 this._boldLines = boldLines;
313 this._editedDescription = cleanContent;
314 }
315
316 _markupNewContent() {
317 const lines = this._editedDescription.trim().split('\n');
318 const markedLines = lines.map((line) => {
319 let markedLine = line;
320 const matchingBoldLine = this._boldLines.find(
321 (boldLine) => (line.startsWith(boldLine)));
322 if (matchingBoldLine) {
323 markedLine =
324 `<b>${matchingBoldLine}</b>${line.slice(matchingBoldLine.length)}`;
325 }
326 return markedLine;
327 });
328 return markedLines.join('\n');
329 }
330
331 /**
332 * Event handler for keeping email state in sync.
333 * @param {Event} e
334 */
335 _setSendEmail(e) {
336 this._sendEmail = e.detail.checked;
337 }
338}
339
340customElements.define('mr-edit-description', MrEditDescription);