blob: 08a8b2588927ad0a9b47d1c81feddd51ec69891f [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 {html, css} from 'lit-element';
6import deepEqual from 'deep-equal';
7
8import 'elements/chops/chops-checkbox/chops-checkbox.js';
9import {store} from 'reducers/base.js';
10import * as issueV0 from 'reducers/issueV0.js';
11import * as userV0 from 'reducers/userV0.js';
12import {prpcClient} from 'prpc-client-instance.js';
13import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
14
15/**
16 * `<mr-update-issue-hotlists-dialog>`
17 *
18 * Displays a dialog with the current hotlists's issues allowing the user to
19 * update which hotlists the issues are a member of.
20 */
21export class MrUpdateIssueDialog extends MrIssueHotlistsDialog {
22 /** @override */
23 static get styles() {
24 return [
25 ...super.styles,
26 css`
27 input[type="checkbox"] {
28 width: auto;
29 height: auto;
30 }
31 button.toggle {
32 background: none;
33 color: hsl(240, 100%, 40%);
34 border: 0;
35 width: 100%;
36 padding: 0.25em 0;
37 text-align: left;
38 }
39 button.toggle:hover {
40 cursor: pointer;
41 text-decoration: underline;
42 }
43 label, chops-checkbox {
44 display: flex;
45 line-height: 200%;
46 align-items: center;
47 width: 100%;
48 text-align: left;
49 font-weight: normal;
50 padding: 0.25em 8px;
51 box-sizing: border-box;
52 }
53 label input[type="checkbox"] {
54 margin-right: 8px;
55 }
56 .discard-button {
57 margin-right: 16px;
58 }
59 .edit-actions {
60 width: 100%;
61 margin: 0.5em 0;
62 text-align: right;
63 }
64 .input-grid {
65 align-items: center;
66 }
67 .input-grid > input {
68 width: 200px;
69 max-width: 100%;
70 }
71 `,
72 ];
73 }
74
75 /** @override */
76 renderHeader() {
77 return html`
78 <h3 class="medium-heading">Add issue to hotlists</h3>
79 `;
80 }
81
82 /** @override */
83 renderContent() {
84 return html`
85 ${this.renderFilter()}
86 <form id="issueHotlistsForm">
87 ${this.renderHotlists()}
88 <h3 class="medium-heading">Create new hotlist</h3>
89 <div class="input-grid">
90 <label for="newHotlistName">New hotlist name:</label>
91 <input type="text" name="newHotlistName">
92 </div>
93 ${this.renderError()}
94 <div class="edit-actions">
95 <chops-button
96 class="de-emphasized discard-button"
97 ?disabled=${this.disabled}
98 @click=${this.discard}
99 >
100 Discard
101 </chops-button>
102 <chops-button
103 class="emphasized"
104 ?disabled=${this.disabled}
105 @click=${this.save}
106 >
107 Save changes
108 </chops-button>
109 </div>
110 </form>
111 `;
112 }
113
114 /** @override */
115 renderFilteredHotlist(hotlist) {
116 return html`
117 <chops-checkbox
118 class="hotlist"
119 title=${this._checkboxTitle(hotlist, this.issueHotlists)}
120 data-hotlist-name="${hotlist.name}"
121 ?checked=${this.hotlistsToAdd.has(hotlist.name)}
122 @checked-change=${this._targetHotlistChecked}
123 >
124 ${hotlist.name}
125 </chops-checkbox>`;
126 }
127
128 /** @override */
129 static get properties() {
130 return {
131 ...super.properties,
132 viewedIssueRef: {type: Object},
133 issueHotlists: {type: Array},
134 user: {type: Object},
135 hotlistsToAdd: {
136 type: Object,
137 hasChanged(newVal, oldVal) {
138 return !deepEqual(newVal, oldVal);
139 },
140 },
141 };
142 }
143
144 /** @override */
145 stateChanged(state) {
146 super.stateChanged(state);
147 this.viewedIssueRef = issueV0.viewedIssueRef(state);
148 this.user = userV0.currentUser(state);
149 }
150
151 /** @override */
152 constructor() {
153 super();
154
155 /** The list of Hotlists attached to the issueRefs. */
156 this.issueHotlists = [];
157
158 /** The Set of Hotlist names that the Issues will be added to. */
159 this.hotlistsToAdd = this._initializeHotlistsToAdd();
160 }
161
162 /** @override */
163 reset() {
164 const form = this.shadowRoot.querySelector('#issueHotlistsForm');
165 form.reset();
166 // LitElement's hasChanged needs an assignment to verify Set objects.
167 // https://lit-element.polymer-project.org/guide/properties#haschanged
168 this.hotlistsToAdd = this._initializeHotlistsToAdd();
169 super.reset();
170 }
171
172 /**
173 * An alias to the close method.
174 */
175 discard() {
176 this.close();
177 }
178
179 /**
180 * Saves all changes that were found in the dialog and issues async requests
181 * to update the issues.
182 * @fires Event#saveSuccess
183 */
184 async save() {
185 const changes = this.changes;
186 const issueRefs = this.issueRefs;
187 const viewedRef = this.viewedIssueRef;
188
189 if (!issueRefs || !changes) return;
190
191 // TODO(https://crbug.com/monorail/7778): Use action creators.
192 const promises = [];
193 if (changes.added && changes.added.length) {
194 promises.push(prpcClient.call(
195 'monorail.Features', 'AddIssuesToHotlists', {
196 hotlistRefs: changes.added,
197 issueRefs,
198 },
199 ));
200 }
201 if (changes.removed && changes.removed.length) {
202 promises.push(prpcClient.call(
203 'monorail.Features', 'RemoveIssuesFromHotlists', {
204 hotlistRefs: changes.removed,
205 issueRefs,
206 },
207 ));
208 }
209 if (changes.created) {
210 promises.push(prpcClient.call(
211 'monorail.Features', 'CreateHotlist', {
212 name: changes.created.name,
213 summary: changes.created.summary,
214 issueRefs,
215 },
216 ));
217 }
218
219 try {
220 await Promise.all(promises);
221
222 // Refresh the viewed issue's hotlists only if there is a viewed issue.
223 if (viewedRef) {
224 const viewedIssueWasUpdated = issueRefs.find((ref) =>
225 ref.projectName === viewedRef.projectName &&
226 ref.localId === viewedRef.localId);
227 if (viewedIssueWasUpdated) {
228 store.dispatch(issueV0.fetchHotlists(viewedRef));
229 }
230 }
231 store.dispatch(userV0.fetchHotlists({userId: this.user.userId}));
232 this.dispatchEvent(new Event('saveSuccess'));
233 this.close();
234 } catch (error) {
235 this.error = error.description;
236 }
237 }
238
239 /**
240 * Returns whether a given hotlist matches any of the given issue's hotlists.
241 * @param {Hotlist} hotlist Hotlist to look for.
242 * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
243 * @return {boolean}
244 */
245 _issueInHotlist(hotlist, issueHotlists) {
246 return issueHotlists.some((issueHotlist) => {
247 // TODO(https://crbug.com/monorail/7451): use `===`.
248 return (hotlist.ownerRef.userId == issueHotlist.ownerRef.userId &&
249 hotlist.name === issueHotlist.name);
250 });
251 }
252
253 /**
254 * Get a Set of Hotlists to add the Issues to based on the
255 * Get the initial Set of Hotlists that Issues will be added to. Calculated
256 * using userHotlists and issueHotlists.
257 * @return {!Set<string>}
258 */
259 _initializeHotlistsToAdd() {
260 const userHotlistsInIssueHotlists = this.userHotlists.reduce(
261 (acc, hotlist) => {
262 if (this._issueInHotlist(hotlist, this.issueHotlists)) {
263 acc.push(hotlist.name);
264 }
265 return acc;
266 }, []);
267 return new Set(userHotlistsInIssueHotlists);
268 }
269
270 /**
271 * Gets the checkbox title, depending on the checked state.
272 * @param {boolean} isChecked Whether the input is checked.
273 * @return {string}
274 */
275 _getCheckboxTitle(isChecked) {
276 return (isChecked ? 'Remove issue from' : 'Add issue to') + ' this hotlist';
277 }
278
279 /**
280 * The checkbox title for the issue, shown on hover and for a11y.
281 * @param {Hotlist} hotlist Hotlist to look for.
282 * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
283 * @return {string}
284 */
285 _checkboxTitle(hotlist, issueHotlists) {
286 return this._getCheckboxTitle(this._issueInHotlist(hotlist, issueHotlists));
287 }
288
289 /**
290 * Handles when the target Hotlist chops-checkbox has been checked.
291 * @param {Event} e
292 */
293 _targetHotlistChecked(e) {
294 const hotlistName = e.target.dataset.hotlistName;
295 const currentHotlistsToAdd = new Set(this.hotlistsToAdd);
296 if (hotlistName && e.detail.checked) {
297 currentHotlistsToAdd.add(hotlistName);
298 } else {
299 currentHotlistsToAdd.delete(hotlistName);
300 }
301 // LitElement's hasChanged needs an assignment to verify Set objects.
302 // https://lit-element.polymer-project.org/guide/properties#haschanged
303 this.hotlistsToAdd = currentHotlistsToAdd;
304 e.target.title = this._getCheckboxTitle(e.target.checked);
305 }
306
307 /**
308 * Gets the changes between the added, removed, and created hotlists .
309 */
310 get changes() {
311 const changes = {
312 added: [],
313 removed: [],
314 };
315 const form = this.shadowRoot.querySelector('#issueHotlistsForm');
316 this.userHotlists.forEach((hotlist) => {
317 const issueInHotlist = this._issueInHotlist(hotlist, this.issueHotlists);
318 if (issueInHotlist && !this.hotlistsToAdd.has(hotlist.name)) {
319 changes.removed.push({
320 name: hotlist.name,
321 owner: hotlist.ownerRef,
322 });
323 } else if (!issueInHotlist && this.hotlistsToAdd.has(hotlist.name)) {
324 changes.added.push({
325 name: hotlist.name,
326 owner: hotlist.ownerRef,
327 });
328 }
329 });
330 if (form.newHotlistName.value) {
331 changes.created = {
332 name: form.newHotlistName.value,
333 summary: 'Hotlist created from issue.',
334 };
335 }
336 return changes;
337 }
338}
339
340customElements.define('mr-update-issue-hotlists-dialog', MrUpdateIssueDialog);