Merge changes from topics "ts-comment-thread", "ts-thread-list"

* changes:
  Convert gr-thread-list to typescript
  Rename files to preserve history
  Convert gr-comment-thread to typescript
  Rename files to preserve history
This commit is contained in:
Ben Rohlfs
2020-09-29 13:05:41 +00:00
committed by Gerrit Code Review
19 changed files with 1136 additions and 978 deletions

View File

@@ -1141,6 +1141,7 @@ class GrChangeView extends KeyboardShortcutMixin(
// selected file index.
const patchRangeState = this.viewState.patchRange;
if (this.viewState.changeNum !== this._changeNum ||
!patchRangeState ||
patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
patchRangeState.patchNum !== this._patchRange.patchNum) {
this._resetFileListViewState();

View File

@@ -821,6 +821,8 @@ suite('gr-change-view tests', () => {
getAllThreadsForChange: () => THREADS,
computeDraftCount: () => 0,
}));
element._change = generateChange();
element._changeNum = element._change._number;
});
test('draft threads should be a new copy with correct states', done => {
@@ -941,8 +943,10 @@ suite('gr-change-view tests', () => {
suite('Findings comment tab', () => {
setup(done => {
element._changeNum = '42';
element._change = {
change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
project: 'testRepo',
revisions: {
rev2: {_number: 2, commit: {parents: []}},
rev1: {_number: 1, commit: {parents: []}},

View File

@@ -1549,6 +1549,7 @@ suite('gr-file-list tests', () => {
element = commentApiWrapper.$.fileList;
loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
element.diffPrefs = {};
element.change = {_number: 42, project: 'testRepo'};
sinon.stub(element, '_reviewFile');
// Stub methods on the changeComments object after changeComments has

View File

@@ -1,308 +0,0 @@
/**
* @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';
import {SpecialFilePath} from '../../../constants/constants.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,
},
_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 ['_updateSortedThreads(threads, threads.splices)'];
}
_computeShowDraftToggle(loggedIn) {
return loggedIn ? 'show' : '';
}
_compareThreads(c1, c2) {
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) {
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; }
}
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);
}
/**
* 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 {Array<Object>} threads
* @param {!Object} spliceRecord
*/
_updateSortedThreads(threads, spliceRecord) {
if (!threads) {
this._sortedThreads = [];
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
const isArrayMutation = spliceRecord &&
(spliceRecord.indexSplices.addedCount !== 0
|| spliceRecord.indexSplices.removed.length);
if (this._sortedThreads
&& this._sortedThreads.length === threads.length
&& !isArrayMutation) {
// 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;
}
const threadsWithInfo = threads
.map(thread => this._getThreadWithStatusInfo(thread));
this._sortedThreads = threadsWithInfo.sort((t1, t2) =>
this._compareThreads(t1, t2)).map(threadInfo => threadInfo.thread);
}
_isFirstThreadWithFileName(sortedThreads, thread, unresolvedOnly, draftsOnly,
onlyShowRobotCommentsWithHumanReply) {
const threads = sortedThreads.filter(t => this._shouldShowThread(
t, unresolvedOnly, draftsOnly,
onlyShowRobotCommentsWithHumanReply));
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(sortedThreads, thread, unresolvedOnly, draftsOnly,
onlyShowRobotCommentsWithHumanReply) {
const threads = sortedThreads.filter(t => this._shouldShowThread(
t, unresolvedOnly, draftsOnly,
onlyShowRobotCommentsWithHumanReply));
const index = threads.findIndex(t => t.rootId === thread.rootId);
if (index === -1) {
return false;
}
return index > 0 && this._isFirstThreadWithFileName(sortedThreads,
thread, unresolvedOnly, draftsOnly,
onlyShowRobotCommentsWithHumanReply);
}
_shouldShowThread(thread, unresolvedOnly, draftsOnly,
onlyShowRobotCommentsWithHumanReply) {
if ([
thread,
unresolvedOnly,
draftsOnly,
onlyShowRobotCommentsWithHumanReply,
].includes(undefined)) {
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) {
const comments = thread.comments;
const lastComment = comments[comments.length - 1] || {};
let hasRobotComment = false;
let hasHumanReplyToRobotComment = false;
comments.forEach(comment => {
if (comment.robot_id) {
hasRobotComment = true;
} else if (hasRobotComment) {
hasHumanReplyToRobotComment = true;
}
});
return {
thread,
hasRobotComment,
hasHumanReplyToRobotComment,
unresolved: !!lastComment.unresolved,
isEditing: !!lastComment.__editing,
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;
}
/**
* Work around a issue on iOS when clicking turns into double tap
*/
_onTapUnresolvedToggle(e) {
e.preventDefault();
}
}
customElements.define(GrThreadList.is, GrThreadList);

View File

@@ -0,0 +1,371 @@
/**
* @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 {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-thread-list_html';
import {parseDate} from '../../../utils/date-util';
import {NO_THREADS_MSG} from '../../../constants/messages';
import {CommentSide, SpecialFilePath} from '../../../constants/constants';
import {customElement, property, observe} from '@polymer/decorators';
import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api';
import {Comment, RobotComment} from '../../shared/gr-comment/gr-comment';
import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
import {ChangeInfo} from '../../../types/common';
interface CommentThreadWithInfo {
thread: CommentThread;
hasRobotComment: boolean;
hasHumanReplyToRobotComment: boolean;
unresolved: boolean;
isEditing: boolean;
hasDraft: boolean;
updated?: Date;
}
@customElement('gr-thread-list')
export class GrThreadList extends GestureEventListeners(
LegacyElementMixin(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})
_unresolvedOnly = false;
@property({type: Boolean})
_draftsOnly = false;
@property({type: Boolean})
onlyShowRobotCommentsWithHumanReply = false;
@property({type: Boolean})
hideToggleButtons = false;
@property({type: String})
emptyThreadMsg = NO_THREADS_MSG;
_computeShowDraftToggle(loggedIn?: boolean) {
return loggedIn ? 'show' : '';
}
_compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
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;
// TODO(TS): Explicit comparison for 'edit' and 'PARENT' missing?
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;
}
/**
* 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 = [];
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;
}
const threadsWithInfo = threads.map(thread =>
this._getThreadWithStatusInfo(thread)
);
this._sortedThreads = threadsWithInfo
.sort((t1, t2) => this._compareThreads(t1, t2))
.map(threadInfo => threadInfo.thread);
}
_isFirstThreadWithFileName(
sortedThreads: CommentThread[],
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
onlyShowRobotCommentsWithHumanReply?: boolean
) {
const threads = sortedThreads.filter(t =>
this._shouldShowThread(
t,
unresolvedOnly,
draftsOnly,
onlyShowRobotCommentsWithHumanReply
)
);
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(
sortedThreads: CommentThread[],
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
onlyShowRobotCommentsWithHumanReply?: boolean
) {
const threads = sortedThreads.filter(t =>
this._shouldShowThread(
t,
unresolvedOnly,
draftsOnly,
onlyShowRobotCommentsWithHumanReply
)
);
const index = threads.findIndex(t => t.rootId === thread.rootId);
if (index === -1) {
return false;
}
return (
index > 0 &&
this._isFirstThreadWithFileName(
sortedThreads,
thread,
unresolvedOnly,
draftsOnly,
onlyShowRobotCommentsWithHumanReply
)
);
}
_shouldShowThread(
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
onlyShowRobotCommentsWithHumanReply?: boolean
) {
if (
[
thread,
unresolvedOnly,
draftsOnly,
onlyShowRobotCommentsWithHumanReply,
].includes(undefined)
) {
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[comments.length - 1] || {}) as Comment;
let hasRobotComment = false;
let hasHumanReplyToRobotComment = false;
comments.forEach(comment => {
if ((comment as RobotComment).robot_id) {
hasRobotComment = true;
} else if (hasRobotComment) {
hasHumanReplyToRobotComment = true;
}
});
return {
thread,
hasRobotComment,
hasHumanReplyToRobotComment,
unresolved: !!lastComment.unresolved,
isEditing: !!lastComment.__editing,
hasDraft: !!lastComment.__draft,
updated: lastComment.updated
? parseDate(lastComment.updated)
: lastComment.__date,
};
}
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) {
this.dispatchEvent(
new CustomEvent('thread-list-modified', {
detail: {rootId: e.detail.rootId, path: e.detail.path},
})
);
}
_isOnParent(side?: CommentSide) {
// TODO(TS): That looks like a bug? CommentSide.REVISION will also be
// classified as parent??
return !!side;
}
/**
* Work around a issue on iOS when clicking turns into double tap
*/
_onTapUnresolvedToggle(e: Event) {
e.preventDefault();
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-thread-list': GrThreadList;
}
}

View File

@@ -36,6 +36,10 @@ suite('gr-thread-list tests', () => {
setup(done => {
element = basicFixture.instantiate();
element.changeNum = 123;
element.change = {
project: 'testRepo',
};
element.threads = [
{
comments: [

View File

@@ -35,6 +35,7 @@ import {
PathToCommentsInfoMap,
PathToRobotCommentsInfoMap,
RobotCommentInfo,
Timestamp,
UrlEncodedCommentId,
ChangeNum,
} from '../../../types/common';
@@ -81,24 +82,27 @@ export function isPatchSetFile(
return !!(x as PatchSetFile).path;
}
export function sortComments<
T extends CommentInfoWithPath | CommentInfoWithTwoPaths
>(comments: T[]): T[] {
interface SortableComment {
__draft?: boolean;
__date?: Date;
updated?: Timestamp;
id?: UrlEncodedCommentId;
}
export function sortComments<T extends SortableComment>(comments: T[]): T[] {
return comments.slice(0).sort((c1, c2) => {
const d1 = !!(c1 as HumanCommentInfoWithPath).__draft;
const d2 = !!(c2 as HumanCommentInfoWithPath).__draft;
const d1 = !!c1.__draft;
const d2 = !!c2.__draft;
if (d1 !== d2) return d1 ? 1 : -1;
const date1 =
(c1.updated && parseDate(c1.updated)) ||
(c1 as HumanCommentInfoWithPath).__date;
const date2 =
(c2.updated && parseDate(c2.updated)) ||
(c2 as HumanCommentInfoWithPath).__date;
const dateDiff = date1.valueOf() - date2.valueOf();
if (dateDiff) {
return dateDiff;
}
return c1.id < c2.id ? -1 : c1.id > c2.id ? 1 : 0;
const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
const dateDiff = date1!.valueOf() - date2!.valueOf();
if (dateDiff !== 0) return dateDiff;
const id1 = c1.id ?? '';
const id2 = c2.id ?? '';
return id1.localeCompare(id2);
});
}
@@ -568,6 +572,7 @@ export class ChangeComments {
const threads: CommentThread[] = [];
const idThreadMap: CommentIdToCommentThreadMap = {};
for (const comment of comments) {
if (!comment.id) continue;
// If the comment is in reply to another comment, find that comment's
// thread and append to it.
if (comment.in_reply_to) {

View File

@@ -219,15 +219,15 @@ suite('gr-comment-api tests', () => {
element._changeComments._drafts = {
'file/one': [
{
id: 11,
id: '11',
patch_set: 2,
side: PARENT,
line: 1,
updated: makeTime(3),
},
{
id: 12,
in_reply_to: 4,
id: '12',
in_reply_to: '04',
patch_set: 2,
line: 1,
// Draft gets lower timestamp than published comment, because we
@@ -237,7 +237,7 @@ suite('gr-comment-api tests', () => {
],
'file/two': [
{
id: 5,
id: '05',
patch_set: 3,
line: 1,
updated: makeTime(3),
@@ -247,7 +247,7 @@ suite('gr-comment-api tests', () => {
element._changeComments._robotComments = {
'file/one': [
{
id: 1,
id: '01',
patch_set: 2,
side: PARENT,
line: 1,
@@ -259,8 +259,8 @@ suite('gr-comment-api tests', () => {
end_character: 2,
},
}, {
id: 2,
in_reply_to: 4,
id: '02',
in_reply_to: '04',
patch_set: 2,
unresolved: true,
line: 1,
@@ -270,27 +270,39 @@ suite('gr-comment-api tests', () => {
};
element._changeComments._comments = {
'file/one': [
{id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
{id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
{
id: '03',
patch_set: 2,
side: PARENT,
line: 2,
updated: makeTime(1),
},
{id: '04', patch_set: 2, line: 1, updated: makeTime(1)},
],
'file/two': [
{id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
{id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
{id: '05', patch_set: 2, line: 2, updated: makeTime(1)},
{id: '06', patch_set: 3, line: 2, updated: makeTime(1)},
],
'file/three': [
{
id: 7,
id: '07',
patch_set: 2,
side: PARENT,
unresolved: true,
line: 1,
updated: makeTime(1),
},
{id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
{id: '08', patch_set: 3, line: 1, updated: makeTime(1)},
],
'file/four': [
{id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
{id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
{
id: '09',
patch_set: 5,
side: PARENT,
line: 1,
updated: makeTime(1),
},
{id: '10', patch_set: 5, line: 1, updated: makeTime(1)},
],
};
});
@@ -500,7 +512,7 @@ suite('gr-comment-api tests', () => {
{
comments: [
{
id: 1,
id: '01',
patch_set: 2,
side: 'PARENT',
line: 1,
@@ -518,11 +530,11 @@ suite('gr-comment-api tests', () => {
patchNum: 2,
path: 'file/one',
line: 1,
rootId: 1,
rootId: '01',
}, {
comments: [
{
id: 3,
id: '03',
patch_set: 2,
side: 'PARENT',
line: 2,
@@ -534,19 +546,19 @@ suite('gr-comment-api tests', () => {
patchNum: 2,
path: 'file/one',
line: 2,
rootId: 3,
rootId: '03',
}, {
comments: [
{
id: 4,
id: '04',
patch_set: 2,
line: 1,
__path: 'file/one',
updated: '2013-02-26 15:01:43.986000000',
},
{
id: 2,
in_reply_to: 4,
id: '02',
in_reply_to: '04',
patch_set: 2,
unresolved: true,
line: 1,
@@ -554,8 +566,8 @@ suite('gr-comment-api tests', () => {
updated: '2013-02-26 15:03:43.986000000',
},
{
id: 12,
in_reply_to: 4,
id: '12',
in_reply_to: '04',
patch_set: 2,
line: 1,
__path: 'file/one',
@@ -566,11 +578,11 @@ suite('gr-comment-api tests', () => {
patchNum: 2,
path: 'file/one',
line: 1,
rootId: 4,
rootId: '04',
}, {
comments: [
{
id: 5,
id: '05',
patch_set: 2,
line: 2,
__path: 'file/two',
@@ -580,11 +592,11 @@ suite('gr-comment-api tests', () => {
patchNum: 2,
path: 'file/two',
line: 2,
rootId: 5,
rootId: '05',
}, {
comments: [
{
id: 6,
id: '06',
patch_set: 3,
line: 2,
__path: 'file/two',
@@ -594,11 +606,11 @@ suite('gr-comment-api tests', () => {
patchNum: 3,
path: 'file/two',
line: 2,
rootId: 6,
rootId: '06',
}, {
comments: [
{
id: 7,
id: '07',
patch_set: 2,
side: 'PARENT',
unresolved: true,
@@ -611,11 +623,11 @@ suite('gr-comment-api tests', () => {
patchNum: 2,
path: 'file/three',
line: 1,
rootId: 7,
rootId: '07',
}, {
comments: [
{
id: 8,
id: '08',
patch_set: 3,
line: 1,
__path: 'file/three',
@@ -625,11 +637,11 @@ suite('gr-comment-api tests', () => {
patchNum: 3,
path: 'file/three',
line: 1,
rootId: 8,
rootId: '08',
}, {
comments: [
{
id: 9,
id: '09',
patch_set: 5,
side: 'PARENT',
line: 1,
@@ -641,25 +653,25 @@ suite('gr-comment-api tests', () => {
patchNum: 5,
path: 'file/four',
line: 1,
rootId: 9,
rootId: '09',
}, {
comments: [
{
id: 10,
id: '10',
patch_set: 5,
line: 1,
__path: 'file/four',
updated: '2013-02-26 15:01:43.986000000',
},
],
rootId: 10,
rootId: '10',
patchNum: 5,
path: 'file/four',
line: 1,
}, {
comments: [
{
id: 5,
id: '05',
patch_set: 3,
line: 1,
__path: 'file/two',
@@ -667,14 +679,14 @@ suite('gr-comment-api tests', () => {
updated: '2013-02-26 15:03:43.986000000',
},
],
rootId: 5,
rootId: '05',
patchNum: 3,
path: 'file/two',
line: 1,
}, {
comments: [
{
id: 11,
id: '11',
patch_set: 2,
side: 'PARENT',
line: 1,
@@ -683,7 +695,7 @@ suite('gr-comment-api tests', () => {
updated: '2013-02-26 15:03:43.986000000',
},
],
rootId: 11,
rootId: '11',
commentSide: 'PARENT',
patchNum: 2,
path: 'file/one',
@@ -698,15 +710,15 @@ suite('gr-comment-api tests', () => {
let expectedComments = [
{
__path: 'file/one',
id: 4,
id: '04',
patch_set: 2,
line: 1,
updated: '2013-02-26 15:01:43.986000000',
},
{
__path: 'file/one',
id: 2,
in_reply_to: 4,
id: '02',
in_reply_to: '04',
patch_set: 2,
unresolved: true,
line: 1,
@@ -715,18 +727,18 @@ suite('gr-comment-api tests', () => {
{
__path: 'file/one',
__draft: true,
id: 12,
in_reply_to: 4,
id: '12',
in_reply_to: '04',
patch_set: 2,
line: 1,
updated: '2013-02-26 15:02:43.986000000',
},
];
assert.deepEqual(element._changeComments.getCommentsForThread(4),
assert.deepEqual(element._changeComments.getCommentsForThread('04'),
expectedComments);
expectedComments = [{
id: 11,
id: '11',
patch_set: 2,
side: 'PARENT',
line: 1,
@@ -735,10 +747,10 @@ suite('gr-comment-api tests', () => {
updated: '2013-02-26 15:03:43.986000000',
}];
assert.deepEqual(element._changeComments.getCommentsForThread(11),
assert.deepEqual(element._changeComments.getCommentsForThread('11'),
expectedComments);
assert.deepEqual(element._changeComments.getCommentsForThread(1000),
assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
null);
});
});

View File

@@ -1,583 +0,0 @@
/**
* @license
* Copyright (C) 2015 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.js';
import '../gr-rest-api-interface/gr-rest-api-interface.js';
import '../gr-storage/gr-storage.js';
import '../gr-comment/gr-comment.js';
import {dom} 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-comment-thread_html.js';
import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {appContext} from '../../../services/app-context.js';
import {SpecialFilePath} from '../../../constants/constants.js';
import {computeDisplayPath} from '../../../utils/path-list-util.js';
import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
/**
* @extends PolymerElement
*/
class GrCommentThread extends KeyboardShortcutMixin(GestureEventListeners(
LegacyElementMixin(PolymerElement))) {
// KeyboardShortcutMixin Not used in this element rather other elements tests
static get template() { return htmlTemplate; }
static get is() { return 'gr-comment-thread'; }
/**
* Fired when the thread should be discarded.
*
* @event thread-discard
*/
/**
* Fired when a comment in the thread is permanently modified.
*
* @event thread-changed
*/
/**
* gr-comment-thread exposes the following attributes that allow a
* diff widget like gr-diff to show the thread in the right location:
*
* line-num:
* 1-based line number or undefined if it refers to the entire file.
*
* comment-side:
* "left" or "right". These indicate which of the two diffed versions
* the comment relates to. In the case of unified diff, the left
* version is the one whose line number column is further to the left.
*
* range:
* The range of text that the comment refers to (start_line,
* start_character, end_line, end_character), serialized as JSON. If
* set, range's end_line will have the same value as line-num. Line
* numbers are 1-based, char numbers are 0-based. The start position
* (start_line, start_character) is inclusive, and the end position
* (end_line, end_character) is exclusive.
*/
static get properties() {
return {
changeNum: String,
comments: {
type: Array,
value() { return []; },
},
/**
* @type {?{start_line: number, start_character: number, end_line: number,
* end_character: number}}
*/
range: {
type: Object,
reflectToAttribute: true,
},
keyEventTarget: {
type: Object,
value() { return document.body; },
},
commentSide: {
type: String,
reflectToAttribute: true,
},
patchNum: String,
path: String,
projectName: {
type: String,
observer: '_projectNameChanged',
},
hasDraft: {
type: Boolean,
notify: true,
reflectToAttribute: true,
},
isOnParent: {
type: Boolean,
value: false,
},
parentIndex: {
type: Number,
value: null,
},
rootId: {
type: String,
notify: true,
computed: '_computeRootId(comments.*)',
},
/**
* If this is true, the comment thread also needs to have the change and
* line properties property set
*/
showFilePath: {
type: Boolean,
value: false,
},
/** Necessary only if showFilePath is true or when used with gr-diff */
lineNum: {
type: Number,
reflectToAttribute: true,
},
unresolved: {
type: Boolean,
notify: true,
reflectToAttribute: true,
},
_showActions: Boolean,
_lastComment: Object,
_orderedComments: Array,
_projectConfig: Object,
isRobotComment: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
showFileName: {
type: Boolean,
value: true,
},
showPatchset: {
type: Boolean,
value: true,
},
};
}
static get observers() {
return [
'_commentsChanged(comments.*)',
];
}
get keyBindings() {
return {
'e shift+e': '_handleEKey',
};
}
constructor() {
super();
this.reporting = appContext.reportingService;
this.flagsService = appContext.flagsService;
}
/** @override */
created() {
super.created();
this.addEventListener('comment-update',
e => this._handleCommentUpdate(e));
}
/** @override */
attached() {
super.attached();
this._getLoggedIn().then(loggedIn => {
this._showActions = loggedIn;
});
this._setInitialExpandedState();
}
addOrEditDraft(opt_lineNum, opt_range) {
const lastComment = this.comments[this.comments.length - 1] || {};
if (lastComment.__draft) {
const commentEl = this._commentElWithDraftID(
lastComment.id || lastComment.__draftID);
commentEl.editing = true;
// If the comment was collapsed, re-open it to make it clear which
// actions are available.
commentEl.collapsed = false;
} else {
const range = opt_range ? opt_range :
lastComment ? lastComment.range : undefined;
const unresolved = lastComment ? lastComment.unresolved : undefined;
this.addDraft(opt_lineNum, range, unresolved);
}
}
addDraft(opt_lineNum, opt_range, opt_unresolved) {
const draft = this._newDraft(opt_lineNum, opt_range);
draft.__editing = true;
draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
this.push('comments', draft);
}
fireRemoveSelf() {
this.dispatchEvent(new CustomEvent('thread-discard',
{detail: {rootId: this.rootId}, bubbles: false}));
}
_getDiffUrlForPath(path) {
if (this.comments[0].__draft) {
return GerritNav.getUrlForDiffById(this.changeNum,
this.projectName, path, this.patchNum);
}
return GerritNav.getUrlForComment(this.changeNum, this.projectName,
this.comments[0].id);
}
_getDiffUrlForComment(projectName, changeNum, path, patchNum) {
if ((this.comments.length && this.comments[0].side === 'PARENT') ||
this.comments[0].__draft) {
return GerritNav.getUrlForDiffById(changeNum,
projectName, path, patchNum, null, this.lineNum);
}
return GerritNav.getUrlForComment(this.changeNum, this.projectName,
this.comments[0].id);
}
_isPatchsetLevelComment(path) {
return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
}
_computeDisplayPath(path) {
const displayPath = computeDisplayPath(path);
if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return `Patchset`;
}
return displayPath;
}
_computeDisplayLine() {
if (this.lineNum) return `#${this.lineNum}`;
// If range is set, then lineNum equals the end line of the range.
if (!this.lineNum && !this.range) {
if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return '';
}
return 'FILE';
}
if (this.range) return `#${this.range.end_line}`;
return '';
}
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
}
_commentsChanged() {
this._orderedComments = sortComments(this.comments);
this.updateThreadProperties();
}
updateThreadProperties() {
if (this._orderedComments.length) {
this._lastComment = this._getLastComment();
this.unresolved = this._lastComment.unresolved;
this.hasDraft = this._lastComment.__draft;
this.isRobotComment = !!(this._lastComment.robot_id);
}
}
_shouldDisableAction(_showActions, _lastComment) {
return !_showActions || !_lastComment || !!_lastComment.__draft;
}
_hideActions(_showActions, _lastComment) {
return this._shouldDisableAction(_showActions, _lastComment) ||
!!_lastComment.robot_id;
}
_getLastComment() {
return this._orderedComments[this._orderedComments.length - 1] || {};
}
_handleEKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
// Dont preventDefault in this case because it will render the event
// useless for other handlers (other gr-comment-thread elements).
if (e.detail.keyboardEvent.shiftKey) {
this._expandCollapseComments(true);
} else {
if (this.modifierPressed(e)) { return; }
this._expandCollapseComments(false);
}
}
_expandCollapseComments(actionIsCollapse) {
const comments =
this.root.querySelectorAll('gr-comment');
for (const comment of comments) {
comment.collapsed = actionIsCollapse;
}
}
/**
* Sets the initial state of the comment thread.
* Expands the thread if one of the following is true:
* - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
* thread is unresolved,
* - it's a robot comment.
*/
_setInitialExpandedState() {
if (this._orderedComments) {
for (let i = 0; i < this._orderedComments.length; i++) {
const comment = this._orderedComments[i];
const isRobotComment = !!comment.robot_id;
// False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
const resolvedThread = !this.unresolved ||
this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
if (comment.collapsed === undefined) {
comment.collapsed = !isRobotComment && resolvedThread;
}
}
}
}
_createReplyComment(content, opt_isEditing,
opt_unresolved) {
this.reporting.recordDraftInteraction();
const reply = this._newReply(
this._orderedComments[this._orderedComments.length - 1].id,
content,
opt_unresolved);
// If there is currently a comment in an editing state, add an attribute
// so that the gr-comment knows not to populate the draft text.
for (let i = 0; i < this.comments.length; i++) {
if (this.comments[i].__editing) {
reply.__otherEditing = true;
break;
}
}
if (opt_isEditing) {
reply.__editing = true;
}
this.push('comments', reply);
if (!opt_isEditing) {
// Allow the reply to render in the dom-repeat.
this.async(() => {
const commentEl = this._commentElWithDraftID(reply.__draftID);
commentEl.save();
}, 1);
}
}
_isDraft(comment) {
return !!comment.__draft;
}
/**
* @param {boolean=} opt_quote
*/
_processCommentReply(opt_quote) {
const comment = this._lastComment;
let quoteStr;
if (opt_quote) {
const msg = comment.message;
quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
}
this._createReplyComment(quoteStr, true, comment.unresolved);
}
_handleCommentReply() {
this._processCommentReply();
}
_handleCommentQuote() {
this._processCommentReply(true);
}
_handleCommentAck() {
this._createReplyComment('Ack', false, false);
}
_handleCommentDone() {
this._createReplyComment('Done', false, false);
}
_handleCommentFix(e) {
const comment = e.detail.comment;
const msg = comment.message;
const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
const response = quoteStr + 'Please fix.';
this._createReplyComment(response, false, true);
}
_commentElWithDraftID(id) {
const els = this.root.querySelectorAll('gr-comment');
for (const el of els) {
if (el.comment.id === id || el.comment.__draftID === id) {
return el;
}
}
return null;
}
_newReply(inReplyTo, opt_message, opt_unresolved) {
const d = this._newDraft();
d.in_reply_to = inReplyTo;
if (opt_message != null) {
d.message = opt_message;
}
if (opt_unresolved !== undefined) {
d.unresolved = opt_unresolved;
}
return d;
}
/**
* @param {number=} opt_lineNum
* @param {!Object=} opt_range
*/
_newDraft(opt_lineNum, opt_range) {
const d = {
__draft: true,
__draftID: Math.random().toString(36),
__date: new Date(),
};
// For replies, always use same meta info as root.
if (this.comments && this.comments.length >= 1) {
const rootComment = this.comments[0];
[
'path',
'patchNum',
'side',
'__commentSide',
'line',
'range',
'parent',
].forEach(key => {
if (rootComment.hasOwnProperty(key)) {
d[key] = rootComment[key];
}
});
} else {
// Set meta info for root comment.
d.path = this.path;
d.patchNum = this.patchNum;
d.side = this._getSide(this.isOnParent);
d.__commentSide = this.commentSide;
if (opt_lineNum) {
d.line = opt_lineNum;
}
if (opt_range) {
d.range = opt_range;
}
if (this.parentIndex) {
d.parent = this.parentIndex;
}
}
return d;
}
_getSide(isOnParent) {
if (isOnParent) { return 'PARENT'; }
return 'REVISION';
}
_computeRootId(comments) {
// Keep the root ID even if the comment was removed, so that notification
// to sync will know which thread to remove.
if (!comments.base.length) { return this.rootId; }
const rootComment = comments.base[0];
return rootComment.id || rootComment.__draftID;
}
_handleCommentDiscard(e) {
const diffCommentEl = dom(e).rootTarget;
const comment = diffCommentEl.comment;
const idx = this._indexOf(comment, this.comments);
if (idx == -1) {
throw Error('Cannot find comment ' +
JSON.stringify(diffCommentEl.comment));
}
this.splice('comments', idx, 1);
if (this.comments.length === 0) {
this.fireRemoveSelf();
}
this._handleCommentSavedOrDiscarded(e);
// Check to see if there are any other open comments getting edited and
// set the local storage value to its message value.
for (const changeComment of this.comments) {
if (changeComment.__editing) {
const commentLocation = {
changeNum: this.changeNum,
patchNum: this.patchNum,
path: changeComment.path,
line: changeComment.line,
};
return this.$.storage.setDraftComment(commentLocation,
changeComment.message);
}
}
}
_handleCommentSavedOrDiscarded(e) {
this.dispatchEvent(new CustomEvent('thread-changed',
{detail: {rootId: this.rootId, path: this.path},
bubbles: false}));
}
_handleCommentUpdate(e) {
const comment = e.detail.comment;
const index = this._indexOf(comment, this.comments);
if (index === -1) {
// This should never happen: comment belongs to another thread.
console.warn('Comment update for another comment thread.');
return;
}
this.set(['comments', index], comment);
// Because of the way we pass these comment objects around by-ref, in
// combination with the fact that Polymer does dirty checking in
// observers, the this.set() call above will not cause a thread update in
// some situations.
this.updateThreadProperties();
}
_indexOf(comment, arr) {
for (let i = 0; i < arr.length; i++) {
const c = arr[i];
if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
(c.id != null && c.id == comment.id)) {
return i;
}
}
return -1;
}
_computeHostClass(unresolved) {
if (this.isRobotComment) {
return 'robotComment';
}
return unresolved ? 'unresolved' : '';
}
/**
* Load the project config when a project name has been provided.
*
* @param {string} name The project name.
*/
_projectNameChanged(name) {
if (!name) { return; }
this.$.restAPI.getProjectConfig(name).then(config => {
this._projectConfig = config;
});
}
}
customElements.define(GrCommentThread.is, GrCommentThread);

View File

@@ -0,0 +1,642 @@
/**
* @license
* Copyright (C) 2015 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 '../gr-rest-api-interface/gr-rest-api-interface';
import '../gr-storage/gr-storage';
import '../gr-comment/gr-comment';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-comment-thread_html';
import {
CustomKeyboardEvent,
KeyboardShortcutMixin,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {sortComments} from '../../diff/gr-comment-api/gr-comment-api';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {appContext} from '../../../services/app-context';
import {CommentSide, SpecialFilePath} from '../../../constants/constants';
import {computeDisplayPath} from '../../../utils/path-list-util';
import {customElement, observe, property} from '@polymer/decorators';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {
CommentRange,
ConfigInfo,
NumericChangeId,
PatchSetNum,
RepoName,
UrlEncodedCommentId,
} from '../../../types/common';
import {Comment, GrComment, RobotComment} from '../gr-comment/gr-comment';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
export interface GrCommentThread {
$: {
restAPI: RestApiService & Element;
storage: GrStorage;
};
}
@customElement('gr-comment-thread')
export class GrCommentThread extends KeyboardShortcutMixin(
GestureEventListeners(LegacyElementMixin(PolymerElement))
) {
// KeyboardShortcutMixin Not used in this element rather other elements tests
static get template() {
return htmlTemplate;
}
/**
* Fired when the thread should be discarded.
*
* @event thread-discard
*/
/**
* Fired when a comment in the thread is permanently modified.
*
* @event thread-changed
*/
/**
* gr-comment-thread exposes the following attributes that allow a
* diff widget like gr-diff to show the thread in the right location:
*
* line-num:
* 1-based line number or undefined if it refers to the entire file.
*
* comment-side:
* "left" or "right". These indicate which of the two diffed versions
* the comment relates to. In the case of unified diff, the left
* version is the one whose line number column is further to the left.
*
* range:
* The range of text that the comment refers to (start_line,
* start_character, end_line, end_character), serialized as JSON. If
* set, range's end_line will have the same value as line-num. Line
* numbers are 1-based, char numbers are 0-based. The start position
* (start_line, start_character) is inclusive, and the end position
* (end_line, end_character) is exclusive.
*/
@property({type: Number})
changeNum?: NumericChangeId;
@property({type: Array})
comments: Comment[] = [];
@property({type: Object, reflectToAttribute: true})
range?: CommentRange;
@property({type: Object})
keyEventTarget: HTMLElement = document.body;
@property({type: String, reflectToAttribute: true})
commentSide?: string;
@property({type: String})
patchNum?: PatchSetNum;
@property({type: String})
path?: string;
@property({type: String, observer: '_projectNameChanged'})
projectName?: RepoName;
@property({type: Boolean, notify: true, reflectToAttribute: true})
hasDraft?: boolean;
@property({type: Boolean})
isOnParent = false;
@property({type: Number})
parentIndex: number | null = null;
@property({
type: String,
notify: true,
computed: '_computeRootId(comments.*)',
})
rootId?: string;
@property({type: Boolean})
showFilePath = false;
@property({type: Number, reflectToAttribute: true})
lineNum?: number;
@property({type: Boolean, notify: true, reflectToAttribute: true})
unresolved?: boolean;
@property({type: Boolean})
_showActions?: boolean;
@property({type: Object})
_lastComment?: Comment;
@property({type: Array})
_orderedComments: Comment[] = [];
@property({type: Object})
_projectConfig?: ConfigInfo;
@property({type: Boolean, reflectToAttribute: true})
isRobotComment = false;
@property({type: Boolean})
showFileName = true;
@property({type: Boolean})
showPatchset = true;
get keyBindings() {
return {
'e shift+e': '_handleEKey',
};
}
reporting = appContext.reportingService;
flagsService = appContext.flagsService;
/** @override */
created() {
super.created();
this.addEventListener('comment-update', e =>
this._handleCommentUpdate(e as CustomEvent)
);
}
/** @override */
attached() {
super.attached();
this._getLoggedIn().then(loggedIn => {
this._showActions = loggedIn;
});
this._setInitialExpandedState();
}
addOrEditDraft(lineNum?: number, rangeParam?: CommentRange) {
const lastComment = this.comments[this.comments.length - 1] || {};
if (lastComment.__draft) {
const commentEl = this._commentElWithDraftID(
lastComment.id || lastComment.__draftID
);
if (!commentEl) throw new Error('Failed to find draft.');
commentEl.editing = true;
// If the comment was collapsed, re-open it to make it clear which
// actions are available.
commentEl.collapsed = false;
} else {
const range = rangeParam
? rangeParam
: lastComment
? lastComment.range
: undefined;
const unresolved = lastComment ? lastComment.unresolved : undefined;
this.addDraft(lineNum, range, unresolved);
}
}
addDraft(lineNum?: number, range?: CommentRange, unresolved?: boolean) {
const draft = this._newDraft(lineNum, range);
draft.__editing = true;
draft.unresolved = unresolved === false ? unresolved : true;
this.push('comments', draft);
}
fireRemoveSelf() {
this.dispatchEvent(
new CustomEvent('thread-discard', {
detail: {rootId: this.rootId},
bubbles: false,
})
);
}
_getDiffUrlForPath(path: string) {
if (!this.changeNum) throw new Error('changeNum is missing');
if (!this.projectName) throw new Error('projectName is missing');
if (this.comments[0].__draft) {
return GerritNav.getUrlForDiffById(
this.changeNum,
this.projectName,
path,
this.patchNum
);
}
const id = this.comments[0].id;
if (!id) throw new Error('A published comment is missing the id.');
return GerritNav.getUrlForComment(this.changeNum, this.projectName, id);
}
_getDiffUrlForComment(
projectName: RepoName,
changeNum: NumericChangeId,
path: string,
patchNum: PatchSetNum
) {
if (
(this.comments.length && this.comments[0].side === 'PARENT') ||
this.comments[0].__draft
) {
return GerritNav.getUrlForDiffById(
changeNum,
projectName,
path,
patchNum,
undefined,
this.lineNum
);
}
const id = this.comments[0].id;
if (!id) throw new Error('A published comment is missing the id.');
if (!this.changeNum) throw new Error('changeNum is missing');
if (!this.projectName) throw new Error('projectName is missing');
return GerritNav.getUrlForComment(this.changeNum, this.projectName, id);
}
_isPatchsetLevelComment(path: string) {
return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
}
_computeDisplayPath(path: string) {
const displayPath = computeDisplayPath(path);
if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return 'Patchset';
}
return displayPath;
}
_computeDisplayLine() {
if (this.lineNum) return `#${this.lineNum}`;
// If range is set, then lineNum equals the end line of the range.
if (!this.lineNum && !this.range) {
if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return '';
}
return 'FILE';
}
if (this.range) return `#${this.range.end_line}`;
return '';
}
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
}
@observe('comments.*')
_commentsChanged() {
this._orderedComments = sortComments(this.comments);
this.updateThreadProperties();
}
updateThreadProperties() {
if (this._orderedComments.length) {
this._lastComment = this._getLastComment();
this.unresolved = this._lastComment.unresolved;
this.hasDraft = this._lastComment.__draft;
this.isRobotComment = !!(this._lastComment as RobotComment).robot_id;
}
}
_shouldDisableAction(_showActions?: boolean, _lastComment?: Comment) {
return !_showActions || !_lastComment || !!_lastComment.__draft;
}
_hideActions(_showActions?: boolean, _lastComment?: Comment) {
return (
this._shouldDisableAction(_showActions, _lastComment) ||
!!(_lastComment as RobotComment).robot_id
);
}
_getLastComment() {
return this._orderedComments[this._orderedComments.length - 1] || {};
}
_handleEKey(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
// Dont preventDefault in this case because it will render the event
// useless for other handlers (other gr-comment-thread elements).
if (e.detail.keyboardEvent?.shiftKey) {
this._expandCollapseComments(true);
} else {
if (this.modifierPressed(e)) {
return;
}
this._expandCollapseComments(false);
}
}
_expandCollapseComments(actionIsCollapse: boolean) {
const comments = this.root?.querySelectorAll('gr-comment');
if (!comments) return;
for (const comment of comments) {
comment.collapsed = actionIsCollapse;
}
}
/**
* Sets the initial state of the comment thread.
* Expands the thread if one of the following is true:
* - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
* thread is unresolved,
* - it's a robot comment.
*/
_setInitialExpandedState() {
if (this._orderedComments) {
for (let i = 0; i < this._orderedComments.length; i++) {
const comment = this._orderedComments[i];
const isRobotComment = !!(comment as RobotComment).robot_id;
// False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
const resolvedThread =
!this.unresolved ||
this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
if (comment.collapsed === undefined) {
comment.collapsed = !isRobotComment && resolvedThread;
}
}
}
}
_createReplyComment(
content?: string,
isEditing?: boolean,
unresolved?: boolean
) {
this.reporting.recordDraftInteraction();
const id = this._orderedComments[this._orderedComments.length - 1].id;
if (!id) throw new Error('Cannot reply to comment without id.');
const reply = this._newReply(id, content, unresolved);
// If there is currently a comment in an editing state, add an attribute
// so that the gr-comment knows not to populate the draft text.
for (let i = 0; i < this.comments.length; i++) {
if (this.comments[i].__editing) {
reply.__otherEditing = true;
break;
}
}
if (isEditing) {
reply.__editing = true;
}
this.push('comments', reply);
if (!isEditing) {
// Allow the reply to render in the dom-repeat.
this.async(() => {
const commentEl = this._commentElWithDraftID(reply.__draftID);
if (commentEl) commentEl.save();
}, 1);
}
}
_isDraft(comment: Comment) {
return !!comment.__draft;
}
_processCommentReply(quote?: boolean) {
const comment = this._lastComment;
if (!comment) throw new Error('Failed to find last comment.');
let content = undefined;
if (quote) {
const msg = comment.message;
if (!msg) throw new Error('Quoting empty comment.');
content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
}
this._createReplyComment(content, true, comment.unresolved);
}
_handleCommentReply() {
this._processCommentReply();
}
_handleCommentQuote() {
this._processCommentReply(true);
}
_handleCommentAck() {
this._createReplyComment('Ack', false, false);
}
_handleCommentDone() {
this._createReplyComment('Done', false, false);
}
_handleCommentFix(e: CustomEvent) {
const comment = e.detail.comment;
const msg = comment.message;
const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
const quoteStr = '> ' + quoted + '\n\n';
const response = quoteStr + 'Please fix.';
this._createReplyComment(response, false, true);
}
_commentElWithDraftID(id?: string): GrComment | null {
if (!id) return null;
const els = this.root?.querySelectorAll('gr-comment');
if (!els) return null;
for (const el of els) {
if (el.comment?.id === id || el.comment?.__draftID === id) {
return el;
}
}
return null;
}
_newReply(
inReplyTo: UrlEncodedCommentId,
message?: string,
unresolved?: boolean
) {
const d = this._newDraft();
d.in_reply_to = inReplyTo;
if (message !== undefined) {
d.message = message;
}
if (unresolved !== undefined) {
d.unresolved = unresolved;
}
return d;
}
_newDraft(lineNum?: number, range?: CommentRange) {
const d: Comment = {
__draft: true,
__draftID: Math.random().toString(36),
__date: new Date(),
};
// For replies, always use same meta info as root.
if (this.comments && this.comments.length >= 1) {
const rootComment = this.comments[0];
if (rootComment.path !== undefined) d.path = rootComment.path;
if (rootComment.patch_set !== undefined)
d.patch_set = rootComment.patch_set;
if (rootComment.side !== undefined) d.side = rootComment.side;
if (rootComment.__commentSide !== undefined)
d.__commentSide = rootComment.__commentSide;
if (rootComment.line !== undefined) d.line = rootComment.line;
if (rootComment.range !== undefined) d.range = rootComment.range;
if (rootComment.parent !== undefined) d.parent = rootComment.parent;
} else {
// Set meta info for root comment.
d.path = this.path;
d.patch_set = this.patchNum;
d.side = this._getSide(this.isOnParent);
d.__commentSide = this.commentSide;
if (lineNum) {
d.line = lineNum;
}
if (range) {
d.range = range;
}
if (this.parentIndex) {
d.parent = this.parentIndex;
}
}
return d;
}
_getSide(isOnParent: boolean): CommentSide {
return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
}
_computeRootId(comments: PolymerDeepPropertyChange<Comment[], unknown>) {
// Keep the root ID even if the comment was removed, so that notification
// to sync will know which thread to remove.
if (!comments.base.length) {
return this.rootId;
}
const rootComment = comments.base[0];
return rootComment.id || rootComment.__draftID;
}
_handleCommentDiscard(e: Event) {
if (!this.changeNum) throw new Error('changeNum is missing');
if (!this.patchNum) throw new Error('patchNum is missing');
const diffCommentEl = (dom(e) as EventApi).rootTarget as GrComment;
const comment = diffCommentEl.comment;
const idx = this._indexOf(comment, this.comments);
if (idx === -1) {
throw Error(
'Cannot find comment ' + JSON.stringify(diffCommentEl.comment)
);
}
this.splice('comments', idx, 1);
if (this.comments.length === 0) {
this.fireRemoveSelf();
}
this._handleCommentSavedOrDiscarded();
// Check to see if there are any other open comments getting edited and
// set the local storage value to its message value.
for (const changeComment of this.comments) {
if (changeComment.__editing) {
const commentLocation: StorageLocation = {
changeNum: this.changeNum,
patchNum: this.patchNum,
path: changeComment.path,
line: changeComment.line,
};
return this.$.storage.setDraftComment(
commentLocation,
changeComment.message ?? ''
);
}
}
}
_handleCommentSavedOrDiscarded() {
this.dispatchEvent(
new CustomEvent('thread-changed', {
detail: {rootId: this.rootId, path: this.path},
bubbles: false,
})
);
}
_handleCommentUpdate(e: CustomEvent) {
const comment = e.detail.comment;
const index = this._indexOf(comment, this.comments);
if (index === -1) {
// This should never happen: comment belongs to another thread.
console.warn('Comment update for another comment thread.');
return;
}
this.set(['comments', index], comment);
// Because of the way we pass these comment objects around by-ref, in
// combination with the fact that Polymer does dirty checking in
// observers, the this.set() call above will not cause a thread update in
// some situations.
this.updateThreadProperties();
}
_indexOf(comment: Comment | undefined, arr: Comment[]) {
if (!comment) return -1;
for (let i = 0; i < arr.length; i++) {
const c = arr[i];
if (
(c.__draftID && c.__draftID === comment.__draftID) ||
(c.id && c.id === comment.id)
) {
return i;
}
}
return -1;
}
_computeHostClass(unresolved?: boolean) {
if (this.isRobotComment) {
return 'robotComment';
}
return unresolved ? 'unresolved' : '';
}
/**
* Load the project config when a project name has been provided.
*
* @param name The project name.
*/
_projectNameChanged(name?: RepoName) {
if (!name) {
return;
}
this.$.restAPI.getProjectConfig(name).then(config => {
this._projectConfig = config;
});
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-comment-thread': GrCommentThread;
}
}

View File

@@ -265,7 +265,7 @@ suite('comment action tests with unresolved thread', () => {
updated: '2015-12-08 19:48:33.843000000',
path: '/path/to/file.txt',
unresolved: true,
patchNum: 3,
patch_set: 3,
__commentSide: 'left',
}];
flush();
@@ -707,7 +707,7 @@ suite('comment action tests with unresolved thread', () => {
test('_newDraft with root', () => {
const draft = element._newDraft();
assert.equal(draft.__commentSide, 'left');
assert.equal(draft.patchNum, 3);
assert.equal(draft.patch_set, 3);
});
test('_newDraft with no root', () => {
@@ -716,7 +716,7 @@ suite('comment action tests with unresolved thread', () => {
element.patchNum = 2;
const draft = element._newDraft();
assert.equal(draft.__commentSide, 'right');
assert.equal(draft.patchNum, 2);
assert.equal(draft.patch_set, 2);
});
test('new comment gets created', () => {

View File

@@ -83,13 +83,14 @@ const RESPECTFUL_REVIEW_TIPS = [
'When disagreeing, explain the advantage of your approach.',
];
interface Draft {
export interface Draft {
collapsed?: boolean;
__editing?: boolean;
__otherEditing?: boolean;
__draft?: boolean;
__draftID?: number;
__draftID?: string;
__commentSide?: string;
__date?: Date;
}
export type Comment = Draft & CommentInfo;
@@ -900,8 +901,9 @@ export class GrComment extends KeyboardShortcutMixin(
throw new Error('undefined changeNum or patchNum');
}
this._showStartRequest();
if (!draft.id) throw new Error('Missing id in comment draft.');
return this.$.restAPI
.deleteDiffDraft(this.changeNum, this.patchNum, draft)
.deleteDiffDraft(this.changeNum, this.patchNum, {id: draft.id})
.then(result => {
if (result.ok) {
this._showEndRequest();
@@ -1020,10 +1022,11 @@ export class GrComment extends KeyboardShortcutMixin(
}
if (
!this.comment ||
!this.comment.id ||
this.changeNum === undefined ||
this.patchNum === undefined
) {
throw new Error('undefined comment or changeNum or patchNum');
throw new Error('undefined comment or id or changeNum or patchNum');
}
this.$.restAPI
.deleteComment(

View File

@@ -34,6 +34,7 @@ import {
import {TimeFormat, DateFormat} from '../../../constants/constants';
import {assertNever} from '../../../utils/common-util';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {Timestamp} from '../../../types/common';
const TimeFormats = {
TIME_12: 'h:mm A', // 2:14 PM
@@ -210,7 +211,7 @@ export class GrDateFormatter extends TooltipMixin(
}
_computeDateStr(
dateStr?: string,
dateStr?: Timestamp,
timeFormat?: string,
dateFormat?: DateFormatPair,
relative?: boolean,
@@ -248,7 +249,7 @@ export class GrDateFormatter extends TooltipMixin(
}
_computeFullDateStr(
dateStr?: string,
dateStr?: Timestamp,
timeFormat?: string,
dateFormat?: DateFormatPair
) {

View File

@@ -2616,9 +2616,11 @@ export class GrRestApiInterface
_setRanges(comments?: CommentInfo[]) {
comments = comments || [];
comments.sort(
(a, b) => parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf()
);
comments.sort((a, b) => {
if (!a.updated) return 1;
if (!b.updated) return -1;
return parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf();
});
for (const comment of comments) {
this._setRange(comments, comment);
}

View File

@@ -23,7 +23,7 @@ import {CommentRange, PatchSetNum} from '../../../types/common';
export interface StorageLocation {
changeNum: number;
patchNum: PatchSetNum;
path: string;
path?: string;
line?: number;
range?: CommentRange;
}

View File

@@ -534,7 +534,7 @@ _describe(
export interface CustomKeyboardEvent extends CustomEvent, EventApi {
event: CustomKeyboardEvent;
detail: {
keyboardEvent?: EventApi;
keyboardEvent?: CustomKeyboardEvent;
// TODO(TS): maybe should mark as optional and check before accessing
key: string;
};

View File

@@ -90,13 +90,14 @@ export function stubBaseUrl(newUrl) {
export function generateChange(options) {
const change = {
_number: 42,
project: 'testRepo',
};
const revisionIdStart = 1;
const messageIdStart = 1000;
// We want to distinguish between empty arrays/objects and undefined
// If an option is not set - the appropriate property is not set
// If an options is set - the property always set
if (typeof options.revisionsCount !== 'undefined') {
if (options && typeof options.revisionsCount !== 'undefined') {
const revisions = {};
for (let i = 0; i < options.revisionsCount; i++) {
const revisionId = (i + revisionIdStart).toString(16);
@@ -107,7 +108,7 @@ export function generateChange(options) {
}
change.revisions = revisions;
}
if (typeof options.messagesCount !== 'undefined') {
if (options && typeof options.messagesCount !== 'undefined') {
const messages = [];
for (let i = 0; i < options.messagesCount; i++) {
messages.push({
@@ -118,7 +119,7 @@ export function generateChange(options) {
}
change.messages = messages;
}
if (options.status) {
if (options && options.status) {
change.status = options.status;
}
return change;

View File

@@ -1049,7 +1049,7 @@ export interface UserConfigInfo {
*/
export interface CommentInfo extends CommentInput {
patch_set?: PatchSetNum;
id: UrlEncodedCommentId;
id?: UrlEncodedCommentId;
path?: string;
side?: CommentSide;
parent?: number;
@@ -1057,7 +1057,7 @@ export interface CommentInfo extends CommentInput {
range?: CommentRange;
in_reply_to?: UrlEncodedCommentId;
message?: string;
updated: Timestamp;
updated?: Timestamp;
author?: AccountInfo;
tag?: string;
unresolved?: boolean;
@@ -1614,7 +1614,7 @@ export interface CommentInput {
line?: number;
range?: CommentRange;
in_reply_to?: UrlEncodedCommentId;
updated: Timestamp;
updated?: Timestamp;
message?: string;
tag?: string;
unresolved?: boolean;

View File

@@ -1,3 +1,5 @@
import {Timestamp} from '../types/common';
/**
* @license
* Copyright (C) 2020 The Android Open Source Project
@@ -20,7 +22,7 @@ const Duration = {
DAY: 1000 * 60 * 60 * 24,
};
export function parseDate(dateStr: string) {
export function parseDate(dateStr: Timestamp) {
// Timestamps are given in UTC and have the format
// "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
// nanoseconds.