blob: dd2ce00fab2e750ab977e6fc63fc8d0f06904d7d [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2019 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// 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/chops/chops-dialog/chops-dialog.js';
8import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
9import {store, connectStore} from 'reducers/base.js';
10import * as issueV0 from 'reducers/issueV0.js';
11import {SHARED_STYLES} from 'shared/shared-styles.js';
12import {ISSUE_EDIT_PERMISSION} from 'shared/consts/permissions';
13import {prpcClient} from 'prpc-client-instance.js';
14
15/**
16 * `<mr-related-issues>`
17 *
18 * Component for showing a mini list view of blocking issues to users.
19 */
20export class MrRelatedIssues extends connectStore(LitElement) {
21 /** @override */
22 static get styles() {
23 return [
24 SHARED_STYLES,
25 css`
26 :host {
27 display: block;
28 font-size: var(--chops-main-font-size);
29 }
30 table {
31 word-wrap: break-word;
32 width: 100%;
33 }
34 tr {
35 font-weight: normal;
36 text-align: left;
37 margin: 0 auto;
38 padding: 2em 1em;
39 height: 20px;
40 }
41 td {
42 background: #f8f8f8;
43 padding: 4px;
44 padding-left: 8px;
45 text-overflow: ellipsis;
46 }
47 th {
48 text-decoration: none;
49 margin-right: 0;
50 padding-right: 0;
51 padding-left: 8px;
52 white-space: nowrap;
53 background: #e3e9ff;
54 text-align: left;
55 border-right: 1px solid #fff;
56 border-top: 1px solid #fff;
57 }
58 tr.dragged td {
59 background: #eee;
60 }
61 h3.medium-heading {
62 display: flex;
63 justify-content: space-between;
64 align-items: flex-end;
65 }
66 button {
67 background: none;
68 border: none;
69 color: inherit;
70 cursor: pointer;
71 margin: 0;
72 padding: 0;
73 }
74 i.material-icons {
75 font-size: var(--chops-icon-font-size);
76 color: var(--chops-primary-icon-color);
77 }
78 .draggable {
79 cursor: grab;
80 }
81 .error {
82 max-width: 100%;
83 color: red;
84 margin-bottom: 1em;
85 }
86 `,
87 ];
88 }
89
90 /** @override */
91 render() {
92 const rerankEnabled = (this.issuePermissions ||
93 []).includes(ISSUE_EDIT_PERMISSION);
94 return html`
95 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
96 <chops-dialog closeOnOutsideClick>
97 <h3 class="medium-heading">
98 <span>Blocked on issues</span>
99 <button aria-label="close" @click=${this.close}>
100 <i class="material-icons">close</i>
101 </button>
102 </h3>
103 ${this.error ? html`
104 <div class="error">${this.error}</div>
105 ` : ''}
106 <table><tbody>
107 <tr>
108 ${rerankEnabled ? html`<th></th>` : ''}
109 ${this.columns.map((column) => html`
110 <th>${column}</th>
111 `)}
112 </tr>
113
114 ${this._renderedRows.map((row, index) => html`
115 <tr
116 class=${index === this.srcIndex ? 'dragged' : ''}
117 draggable=${rerankEnabled && row.draggable}
118 data-index=${index}
119 @dragstart=${this._dragstart}
120 @dragend=${this._dragend}
121 @dragover=${this._dragover}
122 @drop=${this._dragdrop}
123 >
124 ${rerankEnabled ? html`
125 <td>
126 ${rerankEnabled && row.draggable ? html`
127 <i class="material-icons draggable">drag_indicator</i>
128 ` : ''}
129 </td>
130 ` : ''}
131
132 ${row.cells.map((cell) => html`
133 <td>
134 ${cell.type === 'issue' ? html`
135 <mr-issue-link
136 .projectName=${this.issueRef.projectName}
137 .issue=${cell.issue}
138 ></mr-issue-link>
139 ` : ''}
140 ${cell.type === 'text' ? cell.content : ''}
141 </td>
142 `)}
143 </tr>
144 `)}
145 </tbody></table>
146 </chops-dialog>
147 `;
148 }
149
150 /** @override */
151 static get properties() {
152 return {
153 columns: {type: Array},
154 error: {type: String},
155 srcIndex: {type: Number},
156 issueRef: {type: Object},
157 issuePermissions: {type: Array},
158 sortedBlockedOn: {type: Array},
159 _renderedRows: {type: Array},
160 };
161 }
162
163 /** @override */
164 stateChanged(state) {
165 this.issueRef = issueV0.viewedIssueRef(state);
166 this.issuePermissions = issueV0.permissions(state);
167 this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
168 }
169
170 /** @override */
171 constructor() {
172 super();
173 this.columns = ['Issue', 'Summary'];
174 }
175
176 /** @override */
177 update(changedProperties) {
178 if (changedProperties.has('sortedBlockedOn')) {
179 this.reset();
180 }
181 super.update(changedProperties);
182 }
183
184 /** @override */
185 updated(changedProperties) {
186 if (changedProperties.has('issueRef')) {
187 this.close();
188 }
189 }
190
191 get _rows() {
192 const blockedOn = this.sortedBlockedOn;
193 if (!blockedOn) return [];
194 return blockedOn.map((issue) => {
195 const isClosed = issue.statusRef ? !issue.statusRef.meansOpen : false;
196 let summary = issue.summary;
197 if (issue.extIdentifier) {
198 // Some federated references will have summaries.
199 summary = issue.summary || '(not available)';
200 }
201 const row = {
202 // Disallow reranking FedRefs/DanglingIssueRelations.
203 draggable: !isClosed && !issue.extIdentifier,
204 cells: [
205 {
206 type: 'issue',
207 issue: issue,
208 isClosed: Boolean(isClosed),
209 },
210 {
211 type: 'text',
212 content: summary,
213 },
214 ],
215 };
216 return row;
217 });
218 }
219
220 async open() {
221 await this.updateComplete;
222 this.reset();
223 this.shadowRoot.querySelector('chops-dialog').open();
224 }
225
226 close() {
227 this.shadowRoot.querySelector('chops-dialog').close();
228 }
229
230 reset() {
231 this.error = null;
232 this.srcIndex = null;
233 this._renderedRows = this._rows.slice();
234 }
235
236 _dragstart(e) {
237 if (e.currentTarget.draggable) {
238 this.srcIndex = Number(e.currentTarget.dataset.index);
239 e.dataTransfer.setDragImage(new Image(), 0, 0);
240 }
241 }
242
243 _dragover(e) {
244 if (e.currentTarget.draggable && this.srcIndex !== null) {
245 e.preventDefault();
246 const targetIndex = Number(e.currentTarget.dataset.index);
247 this._reorderRows(this.srcIndex, targetIndex);
248 this.srcIndex = targetIndex;
249 }
250 }
251
252 _dragend(e) {
253 if (this.srcIndex !== null) {
254 this.reset();
255 }
256 }
257
258 _dragdrop(e) {
259 if (e.currentTarget.draggable && this.srcIndex !== null) {
260 const src = this._renderedRows[this.srcIndex];
261 if (this.srcIndex > 0) {
262 const target = this._renderedRows[this.srcIndex - 1];
263 const above = false;
264 this._reorderBlockedOn(src, target, above);
265 } else if (this.srcIndex === 0 &&
266 this._renderedRows[1] && this._renderedRows[1].draggable) {
267 const target = this._renderedRows[1];
268 const above = true;
269 this._reorderBlockedOn(src, target, above);
270 }
271 this.srcIndex = null;
272 }
273 }
274
275 _reorderBlockedOn(srcArg, targetArg, above) {
276 const src = srcArg.cells[0].issue;
277 const target = targetArg.cells[0].issue;
278
279 const reorderRequest = prpcClient.call(
280 'monorail.Issues', 'RerankBlockedOnIssues', {
281 issueRef: this.issueRef,
282 movedRef: {
283 projectName: src.projectName,
284 localId: src.localId,
285 },
286 targetRef: {
287 projectName: target.projectName,
288 localId: target.localId,
289 },
290 splitAbove: above,
291 });
292
293 reorderRequest.then((response) => {
294 store.dispatch(issueV0.fetch(this.issueRef));
295 }, (error) => {
296 this.reset();
297 this.error = error.description;
298 });
299 }
300
301 _reorderRows(srcIndex, toIndex) {
302 if (srcIndex <= toIndex) {
303 this._renderedRows = this._renderedRows.slice(0, srcIndex).concat(
304 this._renderedRows.slice(srcIndex + 1, toIndex + 1),
305 [this._renderedRows[srcIndex]],
306 this._renderedRows.slice(toIndex + 1));
307 } else {
308 this._renderedRows = this._renderedRows.slice(0, toIndex).concat(
309 [this._renderedRows[srcIndex]],
310 this._renderedRows.slice(toIndex, srcIndex),
311 this._renderedRows.slice(srcIndex + 1));
312 }
313 }
314}
315
316customElements.define('mr-related-issues', MrRelatedIssues);