/** * @license * Copyright (C) 2018 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import '@polymer/paper-toggle-button/paper-toggle-button.js'; import '../../../styles/shared-styles.js'; import '../../shared/gr-comment-thread/gr-comment-thread.js'; import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js'; import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js'; import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js'; import {PolymerElement} from '@polymer/polymer/polymer-element.js'; import {htmlTemplate} from './gr-thread-list_html.js'; import {parseDate} from '../../../utils/date-util.js'; import {NO_THREADS_MSG} from '../../../constants/messages.js'; /** * Fired when a comment is saved or deleted * * @event thread-list-modified * @extends PolymerElement */ class GrThreadList extends GestureEventListeners( LegacyElementMixin( PolymerElement)) { static get template() { return htmlTemplate; } static get is() { return 'gr-thread-list'; } static get properties() { return { /** @type {?} */ change: Object, threads: Array, changeNum: String, loggedIn: Boolean, _sortedThreads: { type: Array, }, _filteredThreads: { type: Array, computed: '_computeFilteredThreads(_sortedThreads, ' + '_unresolvedOnly, _draftsOnly,' + 'onlyShowRobotCommentsWithHumanReply)', }, _unresolvedOnly: { type: Boolean, value: false, }, _draftsOnly: { type: Boolean, value: false, }, /* Boolean properties used must default to false if passed as attribute by the parent */ onlyShowRobotCommentsWithHumanReply: { type: Boolean, value: false, }, hideToggleButtons: { type: Boolean, value: false, }, emptyThreadMsg: { type: String, value: NO_THREADS_MSG, }, }; } static get observers() { return ['_computeSortedThreads(threads.*)']; } _computeShowDraftToggle(loggedIn) { return loggedIn ? 'show' : ''; } /** * Order as follows: * - Unresolved threads with drafts (reverse chronological) * - Unresolved threads without drafts (reverse chronological) * - Resolved threads with drafts (reverse chronological) * - Resolved threads without drafts (reverse chronological) * * @param {!Object} changeRecord */ _computeSortedThreads(changeRecord) { const baseThreads = changeRecord.base; const threads = changeRecord.value; if (!baseThreads) { return []; } // TODO: should change how data flows to solve the root cause // We only want to sort on thread additions / removals to avoid // re-rendering on modifications (add new reply / edit draft etc) // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation let shouldSort = true; if (threads.indexSplices) { // Array splice mutations shouldSort = threads.indexSplices.addedCount !== 0 || threads.indexSplices.removed.length; } else { // A replace mutation shouldSort = threads.length !== baseThreads.length; } this._updateSortedThreads(baseThreads, shouldSort); } _updateSortedThreads(threads, shouldSort) { const threadsWithInfo = threads.map(this._getThreadWithSortInfo); if (this._sortedThreads && this._sortedThreads.length === threads.length && !shouldSort) { // Instead of replacing the _sortedThreads which will trigger a re-render, // we override all threads inside of it for (const thread of threadsWithInfo) { const idxInSortedThreads = this._sortedThreads .findIndex(t => t.thread.rootId === thread.thread.rootId); this._sortedThreads[idxInSortedThreads] = thread; } return; } this._sortedThreads = threadsWithInfo.sort((c1, c2) => { // threads will be sorted by: // - unresolved first // - with drafts // - file path // - line // - updated time if (c2.unresolved || c1.unresolved) { if (!c1.unresolved) { return 1; } if (!c2.unresolved) { return -1; } } if (c2.hasDraft || c1.hasDraft) { if (!c1.hasDraft) { return 1; } if (!c2.hasDraft) { return -1; } } // TODO: Update here once we introduce patchset level comments // they may not have or have a special line or path attribute if (c1.thread.path !== c2.thread.path) { return c1.thread.path.localeCompare(c2.thread.path); } // File level comments (no `line` property) // should always show before any lines if ([c1, c2].some(c => c.thread.line === undefined)) { if (!c1.thread.line) { return -1; } if (!c2.thread.line) { return 1; } } else if (c1.thread.line !== c2.thread.line) { return c1.thread.line - c2.thread.line; } const c1Date = c1.__date || parseDate(c1.updated); const c2Date = c2.__date || parseDate(c2.updated); const dateCompare = c2Date - c1Date; if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; } return dateCompare ? dateCompare : c1.id.localeCompare(c2.id); }); } _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly, onlyShowRobotCommentsWithHumanReply) { // Polymer 2: check for undefined if ([ sortedThreads, unresolvedOnly, draftsOnly, onlyShowRobotCommentsWithHumanReply, ].some(arg => arg === undefined)) { return undefined; } return sortedThreads.filter(c => { if (draftsOnly) { return c.hasDraft; } else if (unresolvedOnly) { return c.unresolved; } else { const comments = c && c.thread && c.thread.comments; let robotComment = false; let humanReplyToRobotComment = false; comments.forEach(comment => { if (comment.robot_id) { robotComment = true; } else if (robotComment) { // Robot comment exists and human comment exists after it humanReplyToRobotComment = true; } }); if (robotComment && onlyShowRobotCommentsWithHumanReply) { return humanReplyToRobotComment; } return c; } }).map(threadInfo => threadInfo.thread); } _getThreadWithSortInfo(thread) { const lastComment = thread.comments[thread.comments.length - 1] || {}; const lastNonDraftComment = (lastComment.__draft && thread.comments.length > 1) ? thread.comments[thread.comments.length - 2] : lastComment; return { thread, // Use the unresolved bit for the last non draft comment. This is what // anybody other than the current user would see. unresolved: !!lastNonDraftComment.unresolved, hasDraft: !!lastComment.__draft, updated: lastComment.updated || lastComment.__date, }; } removeThread(rootId) { for (let i = 0; i < this.threads.length; i++) { if (this.threads[i].rootId === rootId) { this.splice('threads', i, 1); // Needed to ensure threads get re-rendered in the correct order. flush(); return; } } } _handleThreadDiscard(e) { this.removeThread(e.detail.rootId); } _handleCommentsChanged(e) { this.dispatchEvent(new CustomEvent('thread-list-modified', {detail: {rootId: e.detail.rootId, path: e.detail.path}})); } _isOnParent(side) { return !!side; } } customElements.define(GrThreadList.is, GrThreadList);