blob: 2e8e4bae36d75287d279042bdf67184d395149ef [file] [log] [blame]
/**
* @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';
import '../../../styles/shared-styles';
import '../../shared/gr-comment-thread/gr-comment-thread';
import '../../shared/gr-dropdown-list/gr-dropdown-list';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
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 {AccountInfo, ChangeInfo} from '../../../types/common';
import {
CommentThread,
isDraft,
isUnresolved,
isDraftThread,
isRobotThread,
hasHumanReply,
getCommentAuthors,
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
import {fireThreadListModifiedEvent} from '../../../utils/event-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?: string;
@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})
hideToggleButtons = false;
@property({type: Object, observer: '_commentTabStateChange'})
commentTabState?: CommentTabState;
@property({type: Object})
sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
@property({type: Array, notify: true})
selectedAuthors: AccountInfo[] = [];
@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;
}
_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[]) {
return getCommentAuthors(threads);
}
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);
}
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) {
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 (this._sortedThreads.length === threads.length) {
// 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,
};
}
removeThread(rootId: string) {
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: CustomEvent) {
this.removeThread(e.detail.rootId);
}
_handleCommentsChanged(e: CustomEvent) {
fireThreadListModifiedEvent(this, e.detail.rootId, e.detail.path);
}
_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;
}
}