blob: 0bee4d872e3fc4041a06a9f0d5b7a2e3322526ff [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}
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020083 @beforeinput=${this._setEditedDescription}
Copybara854996b2021-09-07 19:36:02 +000084 @change=${this._setEditedDescription}
85 .value=${this._editedDescription}
86 ></textarea>
87 ${this._renderMarkdown ? html`
88 <div class="markdown-preview preview-height-description">
89 <div class="markdown">
90 ${unsafeHTML(renderMarkdown(this._editedDescription))}
91 </div>
92 </div>`: ''}
93 <h3 class="medium-heading">
94 Add attachments
95 </h3>
96 <div class="attachments">
97 ${this._attachments && this._attachments.map((attachment) => html`
98 <label>
99 <chops-checkbox
100 type="checkbox"
101 checked="true"
102 class="kept-attachment"
103 data-attachment-id=${attachment.attachmentId}
104 @checked-change=${this._keptAttachmentIdsChanged}
105 />
106 <a href=${attachment.viewUrl} target="_blank">
107 ${attachment.filename}
108 </a>
109 </label>
110 <br>
111 `)}
112 <mr-upload></mr-upload>
113 </div>
114 <mr-error
115 ?hidden=${!this._attachmentError}
116 >${this._attachmentError}</mr-error>
117 <div class="edit-controls">
118 <chops-checkbox
119 id="sendEmail"
120 ?checked=${this._sendEmail}
121 @checked-change=${this._setSendEmail}
122 >Send email</chops-checkbox>
123 <div>
124 <chops-button id="discard" @click=${this.cancel} class="de-emphasized">
125 Discard
126 </chops-button>
127 <chops-button id="save" @click=${this.save} class="emphasized">
128 Save changes
129 </chops-button>
130 </div>
131 </div>
132 </chops-dialog>
133 `;
134 }
135
136 /** @override */
137 static get properties() {
138 return {
139 commentsByApproval: {type: Array},
140 issueRef: {type: Object},
141 fieldName: {type: String},
142 projectName: {type: String},
143 _attachmentError: {type: String},
144 _attachments: {type: Array},
145 _boldLines: {type: Array},
146 _editedDescription: {type: String},
147 _title: {type: String},
148 _keptAttachmentIds: {type: Object},
149 _sendEmail: {type: Boolean},
150 _prefs: {type: Object},
151 };
152 }
153
154 /** @override */
155 stateChanged(state) {
156 this.commentsByApproval = issueV0.commentsByApprovalName(state);
157 this.issueRef = issueV0.viewedIssueRef(state);
158 this.projectName = projectV0.viewedProjectName(state);
159 this._prefs = userV0.prefs(state);
160 }
161
162 /**
163 * Public function to open the issue description editing dialog.
164 * @param {Event} e
165 */
166 async open(e) {
167 await this.updateComplete;
168 this.shadowRoot.querySelector('chops-dialog').open();
169 this.fieldName = e.detail.fieldName;
170 this.reset();
171 }
172
173 /**
174 * Resets edit form.
175 */
176 async reset() {
177 await this.updateComplete;
178 this._attachmentError = '';
179 this._attachments = [];
180 this._boldLines = [];
181 this._keptAttachmentIds = new Set();
182
183 const uploader = this.shadowRoot.querySelector('mr-upload');
184 if (uploader) {
185 uploader.reset();
186 }
187
188 // Sets _editedDescription and _title.
189 this._initializeView(this.commentsByApproval, this.fieldName);
190
191 this.shadowRoot.querySelectorAll('.kept-attachment').forEach((checkbox) => {
192 checkbox.checked = true;
193 });
194 this.shadowRoot.querySelector('#sendEmail').checked = true;
195
196 this._sendEmail = true;
197 }
198
199 /**
200 * Cancels in-flight edit data.
201 */
202 async cancel() {
203 await this.updateComplete;
204 this.shadowRoot.querySelector('chops-dialog').close();
205 }
206
207 /**
208 * Sends the user's edit to Monorail's backend to be saved.
209 */
210 async save() {
211 const commentContent = this._markupNewContent();
212 const sendEmail = this._sendEmail;
213 const keptAttachments = Array.from(this._keptAttachmentIds);
214 const message = {
215 issueRef: this.issueRef,
216 isDescription: true,
217 commentContent,
218 keptAttachments,
219 sendEmail,
220 };
221
222 try {
223 const uploader = this.shadowRoot.querySelector('mr-upload');
224 const uploads = await uploader.loadFiles();
225 if (uploads && uploads.length) {
226 message.uploads = uploads;
227 }
228
229 if (!this.fieldName) {
230 store.dispatch(issueV0.update(message));
231 } else {
232 // This is editing an approval if there is no field name.
233 message.fieldRef = {
234 type: fieldTypes.APPROVAL_TYPE,
235 fieldName: this.fieldName,
236 };
237 store.dispatch(issueV0.updateApproval(message));
238 }
239 this.shadowRoot.querySelector('chops-dialog').close();
240 } catch (e) {
241 this._attachmentError = `Error while loading file for attachment: ${
242 e.message}`;
243 }
244 }
245
246 /**
247 * Getter for checking if the user has Markdown enabled.
248 * @return {boolean} Whether Markdown preview should be rendered or not.
249 */
250 get _renderMarkdown() {
251 const enabled = this._prefs.get('render_markdown');
252 return shouldRenderMarkdown({project: this.projectName, enabled});
253 }
254
255 /**
256 * Event handler for keeping <mr-edit-description>'s copy of
257 * _editedDescription in sync.
258 * @param {Event} e
259 */
260 _setEditedDescription(e) {
261 const target = e.target;
262 this._editedDescription = target.value;
263 }
264
265 /**
266 * Event handler for keeping attachment state in sync.
267 * @param {Event} e
268 */
269 _keptAttachmentIdsChanged(e) {
270 e.target.checked = e.detail.checked;
271 const attachmentId = Number.parseInt(e.target.dataset.attachmentId);
272 if (e.target.checked) {
273 this._keptAttachmentIds.add(attachmentId);
274 } else {
275 this._keptAttachmentIds.delete(attachmentId);
276 }
277 }
278
279 _initializeView(commentsByApproval, fieldName) {
280 this._title = fieldName ? `${fieldName} Survey` : 'Description';
281 const key = fieldName || '';
282 if (!commentsByApproval || !commentsByApproval.has(key)) return;
283 const comments = commentListToDescriptionList(commentsByApproval.get(key));
284
285 const comment = comments[comments.length - 1];
286
287 if (comment.attachments) {
288 this._keptAttachmentIds = new Set(comment.attachments.map(
289 (attachment) => Number.parseInt(attachment.attachmentId)));
290 this._attachments = comment.attachments;
291 }
292
293 this._processRawContent(comment.content);
294 }
295
296 _processRawContent(content) {
297 const chunks = content.trim().split(/(<b>[^<\n]+<\/b>)/m);
298 const boldLines = [];
299 let cleanContent = '';
300 chunks.forEach((chunk) => {
301 if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
302 const cleanChunk = chunk.slice(3, -4).trim();
303 cleanContent += cleanChunk;
304 // Don't add whitespace to boldLines.
305 if (/\S/.test(cleanChunk)) {
306 boldLines.push(cleanChunk);
307 }
308 } else {
309 cleanContent += chunk;
310 }
311 });
312
313 this._boldLines = boldLines;
314 this._editedDescription = cleanContent;
315 }
316
317 _markupNewContent() {
318 const lines = this._editedDescription.trim().split('\n');
319 const markedLines = lines.map((line) => {
320 let markedLine = line;
321 const matchingBoldLine = this._boldLines.find(
322 (boldLine) => (line.startsWith(boldLine)));
323 if (matchingBoldLine) {
324 markedLine =
325 `<b>${matchingBoldLine}</b>${line.slice(matchingBoldLine.length)}`;
326 }
327 return markedLine;
328 });
329 return markedLines.join('\n');
330 }
331
332 /**
333 * Event handler for keeping email state in sync.
334 * @param {Event} e
335 */
336 _setSendEmail(e) {
337 this._sendEmail = e.detail.checked;
338 }
339}
340
341customElements.define('mr-edit-description', MrEditDescription);