blob: 2d74c1070091e2f367b071d05eebc4a2bc110dbe [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} from 'lit-element';
6
7import 'elements/chops/chops-dialog/chops-dialog.js';
8import 'elements/chops/chops-collapse/chops-collapse.js';
9import {store, connectStore} from 'reducers/base.js';
10import * as issueV0 from 'reducers/issueV0.js';
11import * as projectV0 from 'reducers/projectV0.js';
12import * as userV0 from 'reducers/userV0.js';
13import * as ui from 'reducers/ui.js';
14import {fieldTypes} from 'shared/issue-fields.js';
15import 'elements/framework/mr-comment-content/mr-description.js';
16import '../mr-comment-list/mr-comment-list.js';
17import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
18import 'elements/issue-detail/metadata/mr-metadata/mr-metadata.js';
19import {APPROVER_RESTRICTED_STATUSES, STATUS_ENUM_TO_TEXT, TEXT_TO_STATUS_ENUM,
20 STATUS_CLASS_MAP, CLASS_ICON_MAP, APPROVAL_STATUSES,
21} from 'shared/consts/approval.js';
22import {commentListToDescriptionList} from 'shared/convertersV0.js';
23import {cueNames, cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js';
24
25
26/**
27 * @type {Array<string>} The list of built in metadata fields to show on
28 * issue approvals.
29 */
30const APPROVAL_METADATA_FIELDS = ['ApprovalStatus', 'Approvers', 'Setter',
31 cueNameToSpec(cueNames.AVAILABILITY_MSGS)];
32
33/**
34 * `<mr-approval-card>`
35 *
36 * This element shows a card for a single approval.
37 *
38 */
39export class MrApprovalCard extends connectStore(LitElement) {
40 /** @override */
41 render() {
42 return html`
43 <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
44 rel="stylesheet">
45 <style>
46 mr-approval-card {
47 width: 100%;
48 background-color: var(--chops-white);
49 font-size: var(--chops-main-font-size);
50 border-bottom: var(--chops-normal-border);
51 box-sizing: border-box;
52 display: block;
53 border-left: 4px solid var(--approval-bg-color);
54
55 /* Default styles are for the NotSet/NeedsReview case. */
56 --approval-bg-color: var(--chops-purple-50);
57 --approval-accent-color: var(--chops-purple-700);
58 }
59 mr-approval-card.status-na {
60 --approval-bg-color: hsl(227, 20%, 92%);
61 --approval-accent-color: hsl(227, 80%, 40%);
62 }
63 mr-approval-card.status-approved {
64 --approval-bg-color: hsl(78, 55%, 90%);
65 --approval-accent-color: hsl(78, 100%, 30%);
66 }
67 mr-approval-card.status-pending {
68 --approval-bg-color: hsl(40, 75%, 90%);
69 --approval-accent-color: hsl(33, 100%, 39%);
70 }
71 mr-approval-card.status-rejected {
72 --approval-bg-color: hsl(5, 60%, 92%);
73 --approval-accent-color: hsl(357, 100%, 39%);
74 }
75 mr-approval-card chops-button.edit-survey {
76 border: var(--chops-normal-border);
77 margin: 0;
78 }
79 mr-approval-card h3 {
80 margin: 0;
81 padding: 0;
82 display: inline;
83 font-weight: inherit;
84 font-size: inherit;
85 line-height: inherit;
86 }
87 mr-approval-card mr-description {
88 display: block;
89 margin-bottom: 0.5em;
90 }
91 .approver-notice {
92 padding: 0.25em 0;
93 width: 100%;
94 display: flex;
95 flex-direction: row;
96 align-items: baseline;
97 justify-content: space-between;
98 border-bottom: 1px dotted hsl(0, 0%, 83%);
99 }
100 .card-content {
101 box-sizing: border-box;
102 padding: 0.5em 16px;
103 padding-bottom: 1em;
104 }
105 .expand-icon {
106 display: block;
107 margin-right: 8px;
108 color: hsl(0, 0%, 45%);
109 }
110 mr-approval-card .header {
111 margin: 0;
112 width: 100%;
113 border: 0;
114 font-size: var(--chops-large-font-size);
115 font-weight: normal;
116 box-sizing: border-box;
117 display: flex;
118 align-items: center;
119 flex-direction: row;
120 padding: 0.5em 8px;
121 background-color: var(--approval-bg-color);
122 cursor: pointer;
123 }
124 mr-approval-card .status {
125 font-size: var(--chops-main-font-size);
126 color: var(--approval-accent-color);
127 display: inline-flex;
128 align-items: center;
129 margin-left: 32px;
130 }
131 mr-approval-card .survey {
132 padding: 0.5em 0;
133 max-height: 500px;
134 overflow-y: auto;
135 max-width: 100%;
136 box-sizing: border-box;
137 }
138 mr-approval-card [role="heading"] {
139 display: flex;
140 flex-direction: row;
141 justify-content: space-between;
142 align-items: flex-end;
143 }
144 mr-approval-card .edit-header {
145 margin-top: 40px;
146 }
147 </style>
148 <button
149 class="header"
150 @click=${this.toggleCard}
151 aria-expanded=${(this.opened || false).toString()}
152 >
153 <i class="material-icons expand-icon">
154 ${this.opened ? 'expand_less' : 'expand_more'}
155 </i>
156 <h3>${this.fieldName}</h3>
157 <span class="status">
158 <i class="material-icons status-icon" role="presentation">
159 ${CLASS_ICON_MAP[this._statusClass]}
160 </i>
161 ${this._status}
162 </span>
163 </button>
164 <chops-collapse class="card-content" ?opened=${this.opened}>
165 <div class="approver-notice">
166 ${this._isApprover ? html`
167 You are an approver for this bit.
168 `: ''}
169 ${this.user && this.user.isSiteAdmin ? html`
170 Your site admin privileges give you full access to edit this approval.
171 `: ''}
172 </div>
173 <mr-metadata
174 aria-label="${this.fieldName} Approval Metadata"
175 .approvalStatus=${this._status}
176 .approvers=${this.approvers}
177 .setter=${this.setter}
178 .fieldDefs=${this.fieldDefs}
179 .builtInFieldSpec=${APPROVAL_METADATA_FIELDS}
180 isApproval
181 ></mr-metadata>
182 <h4
183 class="medium-heading"
184 role="heading"
185 >
186 ${this.fieldName} Survey
187 <chops-button class="edit-survey" @click=${this._openSurveyEditor}>
188 Edit responses
189 </chops-button>
190 </h4>
191 <mr-description
192 class="survey"
193 .descriptionList=${this._allSurveys}
194 ></mr-description>
195 <mr-comment-list
196 headingLevel=4
197 .comments=${this.comments}
198 ></mr-comment-list>
199 ${this.issuePermissions.includes('addissuecomment') ? html`
200 <h4 id="edit${this.fieldName}" class="medium-heading edit-header">
201 Editing approval: ${this.phaseName} &gt; ${this.fieldName}
202 </h4>
203 <mr-edit-metadata
204 .formName="${this.phaseName} > ${this.fieldName}"
205 .approvers=${this.approvers}
206 .fieldDefs=${this.fieldDefs}
207 .statuses=${this._availableStatuses}
208 .status=${this._status}
209 .error=${this.updateError && (this.updateError.description || this.updateError.message)}
210 ?saving=${this.updatingApproval}
211 ?hasApproverPrivileges=${this._hasApproverPrivileges}
212 isApproval
213 @save=${this.save}
214 @discard=${this.reset}
215 ></mr-edit-metadata>
216 ` : ''}
217 </chops-collapse>
218 `;
219 }
220
221 /** @override */
222 static get properties() {
223 return {
224 fieldName: {type: String},
225 approvers: {type: Array},
226 phaseName: {type: String},
227 setter: {type: Object},
228 fieldDefs: {type: Array},
229 focusId: {type: String},
230 user: {type: Object},
231 issue: {type: Object},
232 issueRef: {type: Object},
233 issuePermissions: {type: Array},
234 projectConfig: {type: Object},
235 comments: {type: String},
236 opened: {
237 type: Boolean,
238 reflect: true,
239 },
240 statusEnum: {type: String},
241 updatingApproval: {type: Boolean},
242 updateError: {type: Object},
243 _allSurveys: {type: Array},
244 };
245 }
246
247 /** @override */
248 constructor() {
249 super();
250 this.opened = false;
251 this.comments = [];
252 this.fieldDefs = [];
253 this.issuePermissions = [];
254 this._allSurveys = [];
255 }
256
257 /** @override */
258 createRenderRoot() {
259 return this;
260 }
261
262 /** @override */
263 stateChanged(state) {
264 const fieldDefsByApproval = projectV0.fieldDefsByApprovalName(state);
265 if (fieldDefsByApproval && this.fieldName &&
266 fieldDefsByApproval.has(this.fieldName)) {
267 this.fieldDefs = fieldDefsByApproval.get(this.fieldName);
268 }
269 const commentsByApproval = issueV0.commentsByApprovalName(state);
270 if (commentsByApproval && this.fieldName &&
271 commentsByApproval.has(this.fieldName)) {
272 const comments = commentsByApproval.get(this.fieldName);
273 this.comments = comments.slice(1);
274 this._allSurveys = commentListToDescriptionList(comments);
275 }
276 this.focusId = ui.focusId(state);
277 this.user = userV0.currentUser(state);
278 this.issue = issueV0.viewedIssue(state);
279 this.issueRef = issueV0.viewedIssueRef(state);
280 this.issuePermissions = issueV0.permissions(state);
281 this.projectConfig = projectV0.viewedConfig(state);
282 this.updatingApproval = issueV0.requests(state).updateApproval.requesting;
283 this.updateError = issueV0.requests(state).updateApproval.error;
284 }
285
286 /** @override */
287 update(changedProperties) {
288 if ((changedProperties.has('comments') ||
289 changedProperties.has('focusId')) && this.comments) {
290 const focused = this.comments.find(
291 (comment) => `c${comment.sequenceNum}` === this.focusId);
292 if (focused) {
293 // Make sure to open the card when a comment is focused.
294 this.opened = true;
295 }
296 }
297 if (changedProperties.has('statusEnum')) {
298 this.setAttribute('class', this._statusClass);
299 }
300 if (changedProperties.has('user') || changedProperties.has('approvers')) {
301 if (this._isApprover) {
302 // Open the card by default if the user is an approver.
303 this.opened = true;
304 }
305 }
306 super.update(changedProperties);
307 }
308
309 /** @override */
310 updated(changedProperties) {
311 if (changedProperties.has('issue')) {
312 this.reset();
313 }
314 }
315
316 /**
317 * Resets the approval edit form.
318 */
319 reset() {
320 const form = this.querySelector('mr-edit-metadata');
321 if (!form) return;
322 form.reset();
323 }
324
325 /**
326 * Saves the user's changes in the approval update form.
327 */
328 async save() {
329 const form = this.querySelector('mr-edit-metadata');
330 const delta = form.delta;
331
332 if (delta.status) {
333 delta.status = TEXT_TO_STATUS_ENUM[delta.status];
334 }
335
336 // TODO(ehmaldonado): Show snackbar on change, and prevent starring issues
337 // to resetting the form.
338
339 const message = {
340 issueRef: this.issueRef,
341 fieldRef: {
342 type: fieldTypes.APPROVAL_TYPE,
343 fieldName: this.fieldName,
344 },
345 approvalDelta: delta,
346 commentContent: form.getCommentContent(),
347 sendEmail: form.sendEmail,
348 };
349
350 // Add files to message.
351 const uploads = await form.getAttachments();
352
353 if (uploads && uploads.length) {
354 message.uploads = uploads;
355 }
356
357 if (message.commentContent || message.approvalDelta || message.uploads) {
358 store.dispatch(issueV0.updateApproval(message));
359 }
360 }
361
362 /**
363 * Opens and closes the approval card.
364 */
365 toggleCard() {
366 this.opened = !this.opened;
367 }
368
369 /**
370 * @return {string} The CSS class used to style the approval card,
371 * given its status.
372 * @private
373 */
374 get _statusClass() {
375 return STATUS_CLASS_MAP[this._status];
376 }
377
378 /**
379 * @return {string} The human readable value of an approval status.
380 * @private
381 */
382 get _status() {
383 return STATUS_ENUM_TO_TEXT[this.statusEnum || ''];
384 }
385
386 /**
387 * @return {boolean} Whether the user is an approver or not.
388 * @private
389 */
390 get _isApprover() {
391 // Assumption: Since a user who is an approver should always be a project
392 // member, displayNames should be visible to them if they are an approver.
393 if (!this.approvers || !this.user || !this.user.displayName) return false;
394 const userGroups = this.user.groups || [];
395 return !!this.approvers.find((a) => {
396 return a.displayName === this.user.displayName || userGroups.find(
397 (group) => group.displayName === a.displayName,
398 );
399 });
400 }
401
402 /**
403 * @return {boolean} Whether the user can approver the approval or not.
404 * Not the same as _isApprover because site admins can approve approvals
405 * even if they are not approvers.
406 * @private
407 */
408 get _hasApproverPrivileges() {
409 return (this.user && this.user.isSiteAdmin) || this._isApprover;
410 }
411
412 /**
413 * @return {Array<StatusDef>}
414 * @private
415 */
416 get _availableStatuses() {
417 return APPROVAL_STATUSES.filter((s) => {
418 if (s.status === this._status) {
419 // The current status should always appear as an option.
420 return true;
421 }
422
423 if (!this._hasApproverPrivileges &&
424 APPROVER_RESTRICTED_STATUSES.has(s.status)) {
425 // If you are not an approver and and this status is restricted,
426 // you can't change to this status.
427 return false;
428 }
429
430 // No one can set statuses to NotSet, not even approvers.
431 return s.status !== 'NotSet';
432 });
433 }
434
435 /**
436 * Launches the description editing dialog for the survey.
437 * @fires CustomEvent#open-dialog
438 * @private
439 */
440 _openSurveyEditor() {
441 this.dispatchEvent(new CustomEvent('open-dialog', {
442 bubbles: true,
443 composed: true,
444 detail: {
445 dialogId: 'edit-description',
446 fieldName: this.fieldName,
447 },
448 }));
449 }
450}
451
452customElements.define('mr-approval-card', MrApprovalCard);