| /** |
| * @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 '../../../styles/shared-styles'; |
| import '../../shared/gr-comment-thread/gr-comment-thread'; |
| import '../../shared/gr-dropdown-list/gr-dropdown-list'; |
| |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-thread-list_html'; |
| import {parseDate} from '../../../utils/date-util'; |
| |
| import {CommentSide, SpecialFilePath} from '../../../constants/constants'; |
| import {computed, customElement, observe, property} from '@polymer/decorators'; |
| import { |
| PolymerSpliceChange, |
| PolymerDeepPropertyChange, |
| } from '@polymer/polymer/interfaces'; |
| import { |
| AccountDetailInfo, |
| AccountInfo, |
| ChangeInfo, |
| NumericChangeId, |
| UrlEncodedCommentId, |
| } from '../../../types/common'; |
| import { |
| CommentThread, |
| isDraft, |
| isUnresolved, |
| isDraftThread, |
| isRobotThread, |
| hasHumanReply, |
| getCommentAuthors, |
| computeId, |
| UIComment, |
| } from '../../../utils/comment-util'; |
| import {pluralize} from '../../../utils/string-util'; |
| import {assertIsDefined, assertNever} from '../../../utils/common-util'; |
| import {CommentTabState} from '../../../types/events'; |
| import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list'; |
| import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip'; |
| |
| interface CommentThreadWithInfo { |
| thread: CommentThread; |
| hasRobotComment: boolean; |
| hasHumanReplyToRobotComment: boolean; |
| unresolved: boolean; |
| isEditing: boolean; |
| hasDraft: boolean; |
| updated?: Date; |
| } |
| |
| enum SortDropdownState { |
| TIMESTAMP = 'Latest timestamp', |
| FILES = 'Files', |
| } |
| |
| export const __testOnly_SortDropdownState = SortDropdownState; |
| |
| @customElement('gr-thread-list') |
| export class GrThreadList extends PolymerElement { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| @property({type: Object}) |
| change?: ChangeInfo; |
| |
| @property({type: Array}) |
| threads: CommentThread[] = []; |
| |
| @property({type: String}) |
| changeNum?: NumericChangeId; |
| |
| @property({type: Boolean}) |
| loggedIn?: boolean; |
| |
| @property({type: Array}) |
| _sortedThreads: CommentThread[] = []; |
| |
| @property({type: Boolean}) |
| showCommentContext = false; |
| |
| @property({ |
| computed: |
| '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' + |
| '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)', |
| type: Array, |
| }) |
| _displayedThreads: CommentThread[] = []; |
| |
| // thread-list is used in multiple places like the change log, hence |
| // keeping the default to be false. When used in comments tab, it's |
| // set as true. |
| @property({type: Boolean}) |
| unresolvedOnly = false; |
| |
| @property({type: Boolean}) |
| _draftsOnly = false; |
| |
| @property({type: Boolean}) |
| onlyShowRobotCommentsWithHumanReply = false; |
| |
| @property({type: Boolean}) |
| hideDropdown = false; |
| |
| @property({type: Object, observer: '_commentTabStateChange'}) |
| commentTabState?: CommentTabState; |
| |
| @property({type: Object}) |
| sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP; |
| |
| @property({type: Array, notify: true}) |
| selectedAuthors: AccountInfo[] = []; |
| |
| @property({type: Object}) |
| account?: AccountDetailInfo; |
| |
| @computed('unresolvedOnly', '_draftsOnly') |
| get commentsDropdownValue() { |
| // set initial value and triggered when comment summary chips are clicked |
| if (this._draftsOnly) return CommentTabState.DRAFTS; |
| return this.unresolvedOnly |
| ? CommentTabState.UNRESOLVED |
| : CommentTabState.SHOW_ALL; |
| } |
| |
| @property({type: String}) |
| scrollCommentId?: UrlEncodedCommentId; |
| |
| _showEmptyThreadsMessage( |
| threads: CommentThread[], |
| displayedThreads: CommentThread[], |
| unresolvedOnly: boolean |
| ) { |
| if (!threads || !displayedThreads) return false; |
| return !threads.length || (unresolvedOnly && !displayedThreads.length); |
| } |
| |
| _computeEmptyThreadsMessage(threads: CommentThread[]) { |
| return !threads.length ? 'No comments' : 'No unresolved comments'; |
| } |
| |
| _showPartyPopper(threads: CommentThread[]) { |
| return !!threads.length; |
| } |
| |
| _computeResolvedCommentsMessage( |
| threads: CommentThread[], |
| displayedThreads: CommentThread[], |
| unresolvedOnly: boolean, |
| onlyShowRobotCommentsWithHumanReply: boolean |
| ) { |
| if (onlyShowRobotCommentsWithHumanReply) { |
| threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? []; |
| } |
| if (unresolvedOnly && threads.length && !displayedThreads.length) { |
| return `Show ${pluralize(threads.length, 'resolved comment')}`; |
| } |
| return ''; |
| } |
| |
| _showResolvedCommentsButton( |
| threads: CommentThread[], |
| displayedThreads: CommentThread[], |
| unresolvedOnly: boolean |
| ) { |
| return unresolvedOnly && threads.length && !displayedThreads.length; |
| } |
| |
| _handleResolvedCommentsMessageClick() { |
| this.unresolvedOnly = !this.unresolvedOnly; |
| } |
| |
| getSortDropdownEntires() { |
| return [ |
| {text: SortDropdownState.FILES, value: SortDropdownState.FILES}, |
| {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP}, |
| ]; |
| } |
| |
| getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) { |
| const items: DropdownItem[] = [ |
| { |
| text: `Unresolved (${this._countUnresolved(threads)})`, |
| value: CommentTabState.UNRESOLVED, |
| }, |
| { |
| text: `All (${this._countAllThreads(threads)})`, |
| value: CommentTabState.SHOW_ALL, |
| }, |
| ]; |
| if (loggedIn) |
| items.splice(1, 0, { |
| text: `Drafts (${this._countDrafts(threads)})`, |
| value: CommentTabState.DRAFTS, |
| }); |
| return items; |
| } |
| |
| getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) { |
| return getCommentAuthors(threads, account); |
| } |
| |
| handleAccountClicked(e: MouseEvent) { |
| const account = (e.target as GrAccountChip).account; |
| assertIsDefined(account, 'account'); |
| const index = this.selectedAuthors.findIndex( |
| author => author._account_id === account._account_id |
| ); |
| if (index === -1) this.push('selectedAuthors', account); |
| else this.splice('selectedAuthors', index, 1); |
| // re-assign so that isSelected template method is called |
| this.selectedAuthors = [...this.selectedAuthors]; |
| } |
| |
| isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) { |
| return selectedAuthors.some(a => a._account_id === author._account_id); |
| } |
| |
| computeShouldScrollIntoView( |
| comments: UIComment[], |
| scrollCommentId?: UrlEncodedCommentId |
| ) { |
| const comment = comments?.[0]; |
| if (!comment) return false; |
| return computeId(comment) === scrollCommentId; |
| } |
| |
| handleSortDropdownValueChange(e: CustomEvent) { |
| this.sortDropdownValue = e.detail.value; |
| /* |
| * Ideally we would have updateSortedThreads observe on sortDropdownValue |
| * but the method triggered re-render only when the length of threads |
| * changes, hence keep the explicit resortThreads method |
| */ |
| this.resortThreads(this.threads); |
| } |
| |
| handleCommentsDropdownValueChange(e: CustomEvent) { |
| const value = e.detail.value; |
| if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved(); |
| else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts(); |
| else this._handleAllComments(); |
| } |
| |
| _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) { |
| if ( |
| this.sortDropdownValue === SortDropdownState.TIMESTAMP && |
| !this.hideDropdown |
| ) { |
| if (c1.updated && c2.updated) return c1.updated > c2.updated ? -1 : 1; |
| } |
| |
| if (c1.thread.path !== c2.thread.path) { |
| // '/PATCHSET' will not come before '/COMMIT' when sorting |
| // alphabetically so move it to the front explicitly |
| if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) { |
| return -1; |
| } |
| if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) { |
| return 1; |
| } |
| return c1.thread.path.localeCompare(c2.thread.path); |
| } |
| |
| // Patchset comments have no line/range associated with them |
| if (c1.thread.line !== c2.thread.line) { |
| if (!c1.thread.line || !c2.thread.line) { |
| // one of them is a file level comment, show first |
| return c1.thread.line ? 1 : -1; |
| } |
| return c1.thread.line < c2.thread.line ? -1 : 1; |
| } |
| |
| if (c1.thread.patchNum !== c2.thread.patchNum) { |
| if (!c1.thread.patchNum) return 1; |
| if (!c2.thread.patchNum) return -1; |
| // Threads left on Base when comparing Base vs X have patchNum = X |
| // and CommentSide = PARENT |
| // Threads left on 'edit' have patchNum set as latestPatchNum |
| return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1; |
| } |
| |
| 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; |
| } |
| |
| if (c2.updated !== c1.updated) { |
| if (!c1.updated) return 1; |
| if (!c2.updated) return -1; |
| return c2.updated.getTime() - c1.updated.getTime(); |
| } |
| |
| if (c2.thread.rootId !== c1.thread.rootId) { |
| if (!c1.thread.rootId) return 1; |
| if (!c2.thread.rootId) return -1; |
| return c1.thread.rootId.localeCompare(c2.thread.rootId); |
| } |
| |
| return 0; |
| } |
| |
| resortThreads(threads: CommentThread[]) { |
| const threadsWithInfo = threads.map(thread => |
| this._getThreadWithStatusInfo(thread) |
| ); |
| this._sortedThreads = threadsWithInfo |
| .sort((t1, t2) => this._compareThreads(t1, t2)) |
| .map(threadInfo => threadInfo.thread); |
| } |
| |
| /** |
| * Observer on threads and update _sortedThreads when needed. |
| * Order as follows: |
| * - Patchset level threads (descending based on patchset number) |
| * - unresolved |
| * - comments with drafts |
| * - comments without drafts |
| * - resolved |
| * - comments with drafts |
| * - comments without drafts |
| * - File name |
| * - Line number |
| * - Unresolved (descending based on patchset number) |
| * - comments with drafts |
| * - comments without drafts |
| * - Resolved (descending based on patchset number) |
| * - comments with drafts |
| * - comments without drafts |
| * |
| * @param threads |
| * @param spliceRecord |
| */ |
| @observe('threads', 'threads.splices') |
| _updateSortedThreads( |
| threads: CommentThread[], |
| _: PolymerSpliceChange<CommentThread[]> |
| ) { |
| if (!threads || threads.length === 0) { |
| this._sortedThreads = []; |
| this._displayedThreads = []; |
| return; |
| } |
| // 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 |
| // TODO(TS): We have removed a buggy check of the splices here. A splice |
| // with addedCount > 0 or removed.length > 0 should also cause re-sorting |
| // and re-rendering, but apparently spliceRecord is always undefined for |
| // whatever reason. |
| // If there is an unsaved draftThread which is supposed to be replaced with |
| // a saved draftThread then resort all threads |
| const unsavedThread = this._sortedThreads.some(thread => |
| thread.rootId?.includes('draft__') |
| ); |
| if (this._sortedThreads.length === threads.length && !unsavedThread) { |
| // Instead of replacing the _sortedThreads which will trigger a re-render, |
| // we override all threads inside of it. |
| for (const thread of threads) { |
| const idxInSortedThreads = this._sortedThreads.findIndex( |
| t => t.rootId === thread.rootId |
| ); |
| this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread}); |
| } |
| return; |
| } |
| |
| this.resortThreads(threads); |
| } |
| |
| _computeDisplayedThreads( |
| sortedThreadsRecord?: PolymerDeepPropertyChange< |
| CommentThread[], |
| CommentThread[] |
| >, |
| unresolvedOnly?: boolean, |
| draftsOnly?: boolean, |
| onlyShowRobotCommentsWithHumanReply?: boolean, |
| selectedAuthors?: AccountInfo[] |
| ) { |
| if (!sortedThreadsRecord || !sortedThreadsRecord.base) return []; |
| return sortedThreadsRecord.base.filter(t => |
| this._shouldShowThread( |
| t, |
| unresolvedOnly, |
| draftsOnly, |
| onlyShowRobotCommentsWithHumanReply, |
| selectedAuthors |
| ) |
| ); |
| } |
| |
| _isFirstThreadWithFileName( |
| displayedThreads: CommentThread[], |
| thread: CommentThread, |
| unresolvedOnly?: boolean, |
| draftsOnly?: boolean, |
| onlyShowRobotCommentsWithHumanReply?: boolean, |
| selectedAuthors?: AccountInfo[] |
| ) { |
| const threads = displayedThreads.filter(t => |
| this._shouldShowThread( |
| t, |
| unresolvedOnly, |
| draftsOnly, |
| onlyShowRobotCommentsWithHumanReply, |
| selectedAuthors |
| ) |
| ); |
| const index = threads.findIndex(t => t.rootId === thread.rootId); |
| if (index === -1) { |
| return false; |
| } |
| return index === 0 || threads[index - 1].path !== threads[index].path; |
| } |
| |
| _shouldRenderSeparator( |
| displayedThreads: CommentThread[], |
| thread: CommentThread, |
| unresolvedOnly?: boolean, |
| draftsOnly?: boolean, |
| onlyShowRobotCommentsWithHumanReply?: boolean, |
| selectedAuthors?: AccountInfo[] |
| ) { |
| const threads = displayedThreads.filter(t => |
| this._shouldShowThread( |
| t, |
| unresolvedOnly, |
| draftsOnly, |
| onlyShowRobotCommentsWithHumanReply, |
| selectedAuthors |
| ) |
| ); |
| const index = threads.findIndex(t => t.rootId === thread.rootId); |
| if (index === -1) { |
| return false; |
| } |
| return ( |
| index > 0 && |
| this._isFirstThreadWithFileName( |
| displayedThreads, |
| thread, |
| unresolvedOnly, |
| draftsOnly, |
| onlyShowRobotCommentsWithHumanReply, |
| selectedAuthors |
| ) |
| ); |
| } |
| |
| _shouldShowThread( |
| thread: CommentThread, |
| unresolvedOnly?: boolean, |
| draftsOnly?: boolean, |
| onlyShowRobotCommentsWithHumanReply?: boolean, |
| selectedAuthors?: AccountInfo[] |
| ) { |
| if ( |
| [ |
| thread, |
| unresolvedOnly, |
| draftsOnly, |
| onlyShowRobotCommentsWithHumanReply, |
| selectedAuthors, |
| ].includes(undefined) |
| ) { |
| return false; |
| } |
| |
| if (selectedAuthors!.length) { |
| if ( |
| !thread.comments.some( |
| c => |
| c.author && |
| selectedAuthors!.some( |
| author => c.author!._account_id === author._account_id |
| ) |
| ) |
| ) { |
| return false; |
| } |
| } |
| |
| if ( |
| !draftsOnly && |
| !unresolvedOnly && |
| !onlyShowRobotCommentsWithHumanReply |
| ) { |
| return true; |
| } |
| |
| const threadInfo = this._getThreadWithStatusInfo(thread); |
| |
| if (threadInfo.isEditing) { |
| return true; |
| } |
| |
| if ( |
| threadInfo.hasRobotComment && |
| onlyShowRobotCommentsWithHumanReply && |
| !threadInfo.hasHumanReplyToRobotComment |
| ) { |
| return false; |
| } |
| |
| let filtersCheck = true; |
| if (draftsOnly && unresolvedOnly) { |
| filtersCheck = threadInfo.hasDraft && threadInfo.unresolved; |
| } else if (draftsOnly) { |
| filtersCheck = threadInfo.hasDraft; |
| } else if (unresolvedOnly) { |
| filtersCheck = threadInfo.unresolved; |
| } |
| |
| return filtersCheck; |
| } |
| |
| _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo { |
| const comments = thread.comments; |
| const lastComment = comments.length |
| ? comments[comments.length - 1] |
| : undefined; |
| const hasRobotComment = isRobotThread(thread); |
| const hasHumanReplyToRobotComment = |
| hasRobotComment && hasHumanReply(thread); |
| let updated = undefined; |
| if (lastComment) { |
| if (isDraft(lastComment)) updated = lastComment.__date; |
| if (lastComment.updated) updated = parseDate(lastComment.updated); |
| } |
| |
| return { |
| thread, |
| hasRobotComment, |
| hasHumanReplyToRobotComment, |
| unresolved: !!lastComment && !!lastComment.unresolved, |
| isEditing: isDraft(lastComment) && !!lastComment.__editing, |
| hasDraft: !!lastComment && isDraft(lastComment), |
| updated, |
| }; |
| } |
| |
| _isOnParent(side?: CommentSide) { |
| // TODO(TS): That looks like a bug? CommentSide.REVISION will also be |
| // classified as parent?? |
| return !!side; |
| } |
| |
| _handleOnlyUnresolved() { |
| this.unresolvedOnly = true; |
| this._draftsOnly = false; |
| } |
| |
| _handleOnlyDrafts() { |
| this._draftsOnly = true; |
| this.unresolvedOnly = false; |
| } |
| |
| _handleAllComments() { |
| this._draftsOnly = false; |
| this.unresolvedOnly = false; |
| } |
| |
| _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) { |
| return !draftsOnly && !unresolvedOnly; |
| } |
| |
| _countUnresolved(threads?: CommentThread[]) { |
| return ( |
| this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved) |
| .length ?? 0 |
| ); |
| } |
| |
| _countAllThreads(threads?: CommentThread[]) { |
| return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0; |
| } |
| |
| _countDrafts(threads?: CommentThread[]) { |
| return ( |
| this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread) |
| .length ?? 0 |
| ); |
| } |
| |
| filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) { |
| return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t)); |
| } |
| |
| _commentTabStateChange( |
| newValue?: CommentTabState, |
| oldValue?: CommentTabState |
| ) { |
| if (!newValue || newValue === oldValue) return; |
| let focusTo: string | undefined; |
| switch (newValue) { |
| case CommentTabState.UNRESOLVED: |
| this._handleOnlyUnresolved(); |
| // input is null because it's not rendered yet. |
| focusTo = '#unresolvedRadio'; |
| break; |
| case CommentTabState.DRAFTS: |
| this._handleOnlyDrafts(); |
| focusTo = '#draftsRadio'; |
| break; |
| case CommentTabState.SHOW_ALL: |
| this._handleAllComments(); |
| focusTo = '#allRadio'; |
| break; |
| default: |
| assertNever(newValue, 'Unsupported preferred state'); |
| } |
| const selector = focusTo; |
| window.setTimeout(() => { |
| const input = this.shadowRoot?.querySelector<HTMLInputElement>(selector); |
| input?.focus(); |
| }, 0); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-thread-list': GrThreadList; |
| } |
| } |