blob: 60d570c62ae78ffe7129e8e997648adef1e7424a [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 {connectStore} from 'reducers/base.js';
8import * as issueV0 from 'reducers/issueV0.js';
9import * as projectV0 from 'reducers/projectV0.js';
10import * as userV0 from 'reducers/userV0.js';
11import 'elements/framework/mr-star/mr-issue-star.js';
12import 'elements/framework/links/mr-user-link/mr-user-link.js';
13import 'elements/framework/links/mr-hotlist-link/mr-hotlist-link.js';
14import {SHARED_STYLES} from 'shared/shared-styles.js';
15import {pluralize} from 'shared/helpers.js';
16import './mr-metadata.js';
17
18
19/**
20 * `<mr-issue-metadata>`
21 *
22 * The metadata view for a single issue. Contains information such as the owner.
23 *
24 */
25export class MrIssueMetadata extends connectStore(LitElement) {
26 /** @override */
27 static get styles() {
28 return [
29 SHARED_STYLES,
30 css`
31 :host {
32 box-sizing: border-box;
33 padding: 0.25em 8px;
34 max-width: 100%;
35 display: block;
36 }
37 h3 {
38 display: block;
39 font-size: var(--chops-main-font-size);
40 margin: 0;
41 line-height: 160%;
42 width: 40%;
43 height: 100%;
44 overflow: ellipsis;
45 flex-grow: 0;
46 flex-shrink: 0;
47 }
48 a.label {
49 color: hsl(120, 100%, 25%);
50 text-decoration: none;
51 }
52 a.label[data-derived] {
53 font-style: italic;
54 }
55 button.linkify {
56 display: flex;
57 align-items: center;
58 text-decoration: none;
59 padding: 0.25em 0;
60 }
61 button.linkify i.material-icons {
62 margin-right: 4px;
63 font-size: var(--chops-icon-font-size);
64 }
65 mr-hotlist-link {
66 text-overflow: ellipsis;
67 overflow: hidden;
68 display: block;
69 width: 100%;
70 }
71 .bottom-section-cell, .labels-container {
72 padding: 0.5em 4px;
73 width: 100%;
74 box-sizing: border-box;
75 }
76 .bottom-section-cell {
77 display: flex;
78 flex-direction: row;
79 flex-wrap: nowrap;
80 align-items: flex-start;
81 }
82 .bottom-section-content {
83 max-width: 60%;
84 }
85 .star-line {
86 width: 100%;
87 text-align: center;
88 display: flex;
89 align-items: center;
90 justify-content: center;
91 }
92 mr-issue-star {
93 margin-right: 4px;
94 padding-bottom: 2px;
95 }
96 `,
97 ];
98 }
99
100 /** @override */
101 render() {
102 const hotlistsByRole = this._hotlistsByRole;
103 return html`
104 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
105 <div class="star-line">
106 <mr-issue-star
107 .issueRef=${this.issueRef}
108 ></mr-issue-star>
109 Starred by ${this.issue.starCount || 0} ${pluralize(this.issue.starCount, 'user')}
110 </div>
111 <mr-metadata
112 aria-label="Issue Metadata"
113 .owner=${this.issue.ownerRef}
114 .cc=${this.issue.ccRefs}
115 .issueStatus=${this.issue.statusRef}
116 .components=${this._components}
117 .fieldDefs=${this._fieldDefs}
118 .mergedInto=${this.mergedInto}
119 .modifiedTimestamp=${this.issue.modifiedTimestamp}
120 ></mr-metadata>
121
122 <div class="labels-container">
123 ${this.issue.labelRefs && this.issue.labelRefs.map((label) => html`
124 <a
125 title="${_labelTitle(this.labelDefMap, label)}"
126 href="/p/${this.issueRef.projectName}/issues/list?q=label:${label.label}"
127 class="label"
128 ?data-derived=${label.isDerived}
129 >${label.label}</a>
130 <br>
131 `)}
132 </div>
133
134 ${this.sortedBlockedOn.length ? html`
135 <div class="bottom-section-cell">
136 <h3>BlockedOn:</h3>
137 <div class="bottom-section-content">
138 ${this.sortedBlockedOn.map((issue) => html`
139 <mr-issue-link
140 .projectName=${this.issueRef.projectName}
141 .issue=${issue}
142 >
143 </mr-issue-link>
144 <br />
145 `)}
146 <button
147 class="linkify"
148 @click=${this.openViewBlockedOn}
149 >
150 <i class="material-icons" role="presentation">list</i>
151 View details
152 </button>
153 </div>
154 </div>
155 `: ''}
156
157 ${this.blocking.length ? html`
158 <div class="bottom-section-cell">
159 <h3>Blocking:</h3>
160 <div class="bottom-section-content">
161 ${this.blocking.map((issue) => html`
162 <mr-issue-link
163 .projectName=${this.issueRef.projectName}
164 .issue=${issue}
165 >
166 </mr-issue-link>
167 <br />
168 `)}
169 </div>
170 </div>
171 `: ''}
172
173 ${this._userId ? html`
174 <div class="bottom-section-cell">
175 <h3>Your Hotlists:</h3>
176 <div class="bottom-section-content" id="user-hotlists">
177 ${this._renderHotlists(hotlistsByRole.user)}
178 <button
179 class="linkify"
180 @click=${this.openUpdateHotlists}
181 >
182 <i class="material-icons" role="presentation">create</i> Update your hotlists
183 </button>
184 </div>
185 </div>
186 `: ''}
187
188 ${hotlistsByRole.participants.length ? html`
189 <div class="bottom-section-cell">
190 <h3>Participant's Hotlists:</h3>
191 <div class="bottom-section-content">
192 ${this._renderHotlists(hotlistsByRole.participants)}
193 </div>
194 </div>
195 ` : ''}
196
197 ${hotlistsByRole.others.length ? html`
198 <div class="bottom-section-cell">
199 <h3>Other Hotlists:</h3>
200 <div class="bottom-section-content">
201 ${this._renderHotlists(hotlistsByRole.others)}
202 </div>
203 </div>
204 ` : ''}
205 `;
206 }
207
208 /**
209 * Helper to render hotlists.
210 * @param {Array<Hotlist>} hotlists
211 * @return {Array<TemplateResult>}
212 * @private
213 */
214 _renderHotlists(hotlists) {
215 return hotlists.map((hotlist) => html`
216 <mr-hotlist-link .hotlist=${hotlist}></mr-hotlist-link>
217 `);
218 }
219
220 /** @override */
221 static get properties() {
222 return {
223 issue: {type: Object},
224 issueRef: {type: Object},
225 projectConfig: String,
226 user: {type: Object},
227 issueHotlists: {type: Array},
228 blocking: {type: Array},
229 sortedBlockedOn: {type: Array},
230 relatedIssues: {type: Object},
231 labelDefMap: {type: Object},
232 _components: {type: Array},
233 _fieldDefs: {type: Array},
234 _type: {type: String},
235 };
236 }
237
238 /** @override */
239 stateChanged(state) {
240 this.issue = issueV0.viewedIssue(state);
241 this.issueRef = issueV0.viewedIssueRef(state);
242 this.user = userV0.currentUser(state);
243 this.projectConfig = projectV0.viewedConfig(state);
244 this.blocking = issueV0.blockingIssues(state);
245 this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
246 this.mergedInto = issueV0.mergedInto(state);
247 this.relatedIssues = issueV0.relatedIssues(state);
248 this.issueHotlists = issueV0.hotlists(state);
249 this.labelDefMap = projectV0.labelDefMap(state);
250 this._components = issueV0.components(state);
251 this._fieldDefs = issueV0.fieldDefs(state);
252 this._type = issueV0.type(state);
253 }
254
255 /**
256 * @return {string|number} The current user's userId.
257 * @private
258 */
259 get _userId() {
260 return this.user && this.user.userId;
261 }
262
263 /**
264 * @return {Object<string, Array<Hotlist>>}
265 * @private
266 */
267 get _hotlistsByRole() {
268 const issueHotlists = this.issueHotlists;
269 const owner = this.issue && this.issue.ownerRef;
270 const cc = this.issue && this.issue.ccRefs;
271
272 const hotlists = {
273 user: [],
274 participants: [],
275 others: [],
276 };
277 (issueHotlists || []).forEach((hotlist) => {
278 if (hotlist.ownerRef.userId === this._userId) {
279 hotlists.user.push(hotlist);
280 } else if (_userIsParticipant(hotlist.ownerRef, owner, cc)) {
281 hotlists.participants.push(hotlist);
282 } else {
283 hotlists.others.push(hotlist);
284 }
285 });
286 return hotlists;
287 }
288
289 /**
290 * Opens dialog for updating ths issue's hotlists.
291 * @fires CustomEvent#open-dialog
292 */
293 openUpdateHotlists() {
294 this.dispatchEvent(new CustomEvent('open-dialog', {
295 bubbles: true,
296 composed: true,
297 detail: {
298 dialogId: 'update-issue-hotlists',
299 },
300 }));
301 }
302
303 /**
304 * Opens dialog with detailed view of blocked on issues.
305 * @fires CustomEvent#open-dialog
306 */
307 openViewBlockedOn() {
308 this.dispatchEvent(new CustomEvent('open-dialog', {
309 bubbles: true,
310 composed: true,
311 detail: {
312 dialogId: 'reorder-related-issues',
313 },
314 }));
315 }
316}
317
318/**
319 * @param {UserRef} user
320 * @param {UserRef} owner
321 * @param {Array<UserRef>} cc
322 * @return {boolean} Whether a given user is a participant of
323 * a given hotlist attached to an issue. Used to sort hotlists into
324 * "My hotlists" and "Other hotlists".
325 * @private
326 */
327function _userIsParticipant(user, owner, cc) {
328 if (owner && owner.userId === user.userId) {
329 return true;
330 }
331 return cc && cc.some((ccUser) => ccUser && ccUser.userId === user.userId);
332}
333
334/**
335 * @param {Map.<string, LabelDef>} labelDefMap
336 * @param {LabelDef} label
337 * @return {string} Tooltip shown to the user when hovering over a
338 * given label.
339 * @private
340 */
341function _labelTitle(labelDefMap, label) {
342 if (!label) return '';
343 let docstring = '';
344 const key = label.label.toLowerCase();
345 if (labelDefMap && labelDefMap.has(key)) {
346 docstring = labelDefMap.get(key).docstring;
347 }
348 return (label.isDerived ? 'Derived: ' : '') + label.label +
349 (docstring ? ` = ${docstring}` : '');
350}
351
352customElements.define('mr-issue-metadata', MrIssueMetadata);