Merge "Refactor keys transformation in comment context"
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
index 2466b4f..1efbd37 100644
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalInference.java
@@ -30,7 +30,6 @@
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.change.ChangeKindCache;
import com.google.gerrit.server.change.LabelNormalizer;
@@ -40,7 +39,6 @@
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.PatchListKey;
import com.google.gerrit.server.patch.filediff.FileDiffOutput;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
@@ -433,11 +431,6 @@
*/
private Map<String, FileDiffOutput> listModifiedFiles(
ProjectState project, PatchSet ps, Map.Entry<PatchSet.Id, PatchSet> priorPatchSet) {
- PatchListKey key =
- PatchListKey.againstCommit(
- priorPatchSet.getValue().commitId(),
- ps.commitId(),
- DiffPreferencesInfo.Whitespace.IGNORE_NONE);
try {
return diffOperations.listModifiedFiles(
project.getNameKey(), priorPatchSet.getValue().commitId(), ps.commitId());
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonModule.java b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
index f90bd14..de116bb 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonModule.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
@@ -14,12 +14,31 @@
package com.google.gerrit.server.change;
+import com.google.gerrit.server.config.GerritServerConfig;
import com.google.inject.AbstractModule;
+import org.eclipse.jgit.lib.Config;
public class FileInfoJsonModule extends AbstractModule {
+ /** Use the new diff cache implementation {@link FileInfoJsonNewImpl}. */
+ private final boolean useNewDiffCache;
+
+ /** Used to dark launch the new diff cache with the list files endpoint. */
+ private final boolean runNewDiffCacheAsync;
+
+ public FileInfoJsonModule(@GerritServerConfig Config cfg) {
+ this.useNewDiffCache =
+ cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
+ this.runNewDiffCacheAsync =
+ cfg.getBoolean("cache", "diff_cache", "runNewDiffCacheAsync_listFiles", false);
+ }
@Override
public void configure() {
- bind(FileInfoJson.class).to(FileInfoJsonComparingImpl.class);
+ if (runNewDiffCacheAsync) {
+ bind(FileInfoJson.class).to(FileInfoJsonComparingImpl.class);
+ return;
+ }
+ bind(FileInfoJson.class)
+ .to(useNewDiffCache ? FileInfoJsonNewImpl.class : FileInfoJsonOldImpl.class);
}
}
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
index 99a89bf..9216964 100644
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -26,7 +26,6 @@
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
import java.io.IOException;
-import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
@@ -90,17 +89,6 @@
getMatchingRefNames(allMatchingTagsAndBranches, tags));
}
- private boolean includedInOne(Collection<Ref> refs) throws IOException {
- parseCommits(refs);
- List<RevCommit> before = new ArrayList<>();
- List<RevCommit> after = new ArrayList<>();
- partition(before, after);
- rw.reset();
- // It is highly likely that the target is reachable from the "after" set
- // Within the "before" set we are trying to handle cases arising from clock skew
- return !includedIn(after, 1).isEmpty() || !includedIn(before, 1).isEmpty();
- }
-
/** Resolves which tip refs include the target commit. */
private Set<String> includedIn(Collection<RevCommit> tips, int limit)
throws IOException, MissingObjectException, IncorrectObjectTypeException {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 076ba46..5a74c78 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -271,7 +271,7 @@
install(new IgnoreSelfApprovalRule.Module());
install(new ReceiveCommitsModule());
install(new SshAddressesModule());
- install(new FileInfoJsonModule());
+ install(new FileInfoJsonModule(cfg));
install(ThreadLocalRequestContext.module());
install(new ApprovalModule());
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 2bf9e54..2adebe7 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -31,6 +31,8 @@
import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.ReachabilityChecker;
@@ -77,7 +79,19 @@
.filter(refs, repo, RefFilterOptions.defaults());
Collection<RevCommit> visible = new ArrayList<>();
for (Ref r : filtered) {
- visible.add(rw.parseCommit(r.getObjectId()));
+ try {
+ visible.add(rw.parseCommit(r.getObjectId()));
+ } catch (IncorrectObjectTypeException notCommit) {
+ // Its OK for a tag reference to point to a blob or a tree, this
+ // is common in the Linux kernel or git.git repository.
+ continue;
+ } catch (MissingObjectException notHere) {
+ // Log the problem with this branch, but keep processing.
+ logger.atWarning().log(
+ "Reference %s in %s points to dangling object %s",
+ r.getName(), repo.getDirectory(), r.getObjectId());
+ continue;
+ }
}
// The filtering above already produces a voluminous trace. To separate the permission check
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index cd5032a..7ca763a1 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -250,7 +250,7 @@
install(new RestApiModule());
install(new OAuthRestModule());
install(new DefaultProjectNameLockManager.Module());
- install(new FileInfoJsonModule());
+ install(new FileInfoJsonModule(cfg));
bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
}
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 42d5fe0..e0a6721 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 42d5fe041ee2ef6be579c0085396fa5e60889222
+Subproject commit e0a67217ae5359797570481cbb6e8aa1f5e0a7c3
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 2682158..06f9509 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -28,6 +28,23 @@
* polling interval to pass.
*/
announceUpdate(): void;
+
+ /**
+ * Updates an individual result.
+ *
+ * This can be used for lazy loading detailed information. For example, if you
+ * are using the `check-result-expanded` endpoint, then you can load more
+ * result details when the user expands a result row.
+ *
+ * The parameter `run` is only used to *find* the correct run for updating the
+ * result. It will only be used for comparing `change`, `patchset`, `attempt`
+ * and `checkName`. Its properties other than `results` will not be updated.
+ *
+ * For us being able to identify the result that you want to update you have
+ * to set the `externalId` property. An undefined `externalId` will result in
+ * an error.
+ */
+ updateResult(run: CheckRun, result: CheckResult): void;
}
export declare interface ChecksApiConfig {
@@ -450,4 +467,5 @@
HELP_PAGE = 'help_page',
REPORT_BUG = 'report_bug',
CODE = 'code',
+ FILE_PRESENT = 'file_present',
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 251fbf7..c78d6c0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -36,10 +36,11 @@
getResultsOf,
hasCompletedWithoutResults,
hasResultsOf,
- iconForCategory,
- iconForStatus,
+ iconFor,
isRunning,
isRunningOrHasCompleted,
+ isStatus,
+ labelFor,
} from '../../../services/checks/checks-util';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {
@@ -57,6 +58,7 @@
import {PrimaryTab} from '../../../constants/constants';
import {ChecksTabState, CommentTabState} from '../../../types/events';
import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {modifierPressed} from '../../../utils/dom-util';
export enum SummaryChipStyles {
INFO = 'info',
@@ -106,6 +108,9 @@
background: var(--warning-background-hover);
box-shadow: var(--elevation-level-1);
}
+ .summaryChip.warning:focus-within {
+ background: var(--warning-background-focus);
+ }
.summaryChip.warning iron-icon {
color: var(--warning-foreground);
}
@@ -117,6 +122,9 @@
background: var(--gray-background-hover);
box-shadow: var(--elevation-level-1);
}
+ .summaryChip.check:focus-within {
+ background: var(--gray-background-focus);
+ }
.summaryChip.check iron-icon {
color: var(--gray-foreground);
}
@@ -148,7 +156,7 @@
@customElement('gr-checks-chip')
export class GrChecksChip extends GrLitElement {
@property()
- icon = '';
+ statusOrCategory?: Category | RunStatus;
@property()
text = '';
@@ -193,6 +201,9 @@
background: var(--error-background-hover);
box-shadow: var(--elevation-level-1);
}
+ .checksChip.error:focus-within {
+ background: var(--error-background-focus);
+ }
.checksChip.error iron-icon {
color: var(--error-foreground);
}
@@ -204,6 +215,9 @@
background: var(--warning-background-hover);
box-shadow: var(--elevation-level-1);
}
+ .checksChip.warning:focus-within {
+ background: var(--warning-background-focus);
+ }
.checksChip.warning iron-icon {
color: var(--warning-foreground);
}
@@ -215,6 +229,9 @@
background: var(--info-background-hover);
box-shadow: var(--elevation-level-1);
}
+ .checksChip.info-outline:focus-within {
+ background: var(--info-background-focus);
+ }
.checksChip.info-outline iron-icon {
color: var(--info-foreground);
}
@@ -226,6 +243,9 @@
background: var(--success-background-hover);
box-shadow: var(--elevation-level-1);
}
+ .checksChip.check-circle-outline:focus-within {
+ background: var(--success-background-focus);
+ }
.checksChip.check-circle-outline iron-icon {
color: var(--success-foreground);
}
@@ -239,6 +259,9 @@
background: var(--gray-background-hover);
box-shadow: var(--elevation-level-1);
}
+ .checksChip.timelapse:focus-within {
+ background: var(--gray-background-focus);
+ }
.checksChip.timelapse iron-icon {
color: var(--gray-foreground);
}
@@ -248,10 +271,25 @@
render() {
if (!this.text) return;
- const chipClass = `checksChip font-small ${this.icon}`;
- const grIcon = `gr-icons:${this.icon}`;
+ if (!this.statusOrCategory) return;
+ const icon = iconFor(this.statusOrCategory);
+ const label = labelFor(this.statusOrCategory);
+ const count = Number(this.text);
+ let ariaLabel = label;
+ if (!isNaN(count)) {
+ const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
+ const plural = count > 1 ? 's' : '';
+ ariaLabel = `${this.text} ${label} ${type}${plural}`;
+ }
+ const chipClass = `checksChip font-small ${icon}`;
+ const grIcon = `gr-icons:${icon}`;
return html`
- <div class="${chipClass}" role="button">
+ <div
+ class="${chipClass}"
+ role="link"
+ tabindex="0"
+ aria-label="${ariaLabel}"
+ >
<iron-icon icon="${grIcon}"></iron-icon>
<div class="text">${this.text}</div>
<slot></slot>
@@ -411,18 +449,17 @@
if (this.errorMessage || this.loginCallback) return;
if (this.runs.some(isRunningOrHasCompleted)) return;
const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
- return html`<span class="loading zeroState">${msg}</span>`;
+ return html`<span role="status" class="loading zeroState">${msg}</span>`;
}
renderChecksChipForCategory(category: Category) {
if (this.errorMessage || this.loginCallback) return;
- const icon = iconForCategory(category);
const runs = this.runs.filter(run => {
if (hasResultsOf(run, category)) return true;
return category === Category.SUCCESS && hasCompletedWithoutResults(run);
});
const count = (run: CheckRun) => getResultsOf(run, category);
- return this.renderChecksChip(icon, runs, category, count);
+ return this.renderChecksChip(runs, category, count);
}
renderChecksChipForStatus(
@@ -430,13 +467,11 @@
filter: (run: CheckRun) => boolean
) {
if (this.errorMessage || this.loginCallback) return;
- const icon = iconForStatus(status);
const runs = this.runs.filter(filter);
- return this.renderChecksChip(icon, runs, status, () => []);
+ return this.renderChecksChip(runs, status, () => []);
}
renderChecksChip(
- icon: string,
runs: CheckRun[],
statusOrCategory: RunStatus | Category,
resultFilter: (run: CheckRun) => CheckResult[]
@@ -462,13 +497,19 @@
const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
const text = `${run.checkName}`;
return html`<gr-checks-chip
- class="${icon}"
- .icon="${icon}"
+ .statusOrCategory="${statusOrCategory}"
.text="${text}"
@click="${() => this.onChipClick({checkName: run.checkName})}"
+ @keydown="${(e: KeyboardEvent) =>
+ this.onChipKeyDown(e, {checkName: run.checkName})}"
>${links.map(
link => html`
- <a href="${link.url}" target="_blank" @click="${this.onLinkClick}"
+ <a
+ href="${link.url}"
+ target="_blank"
+ @click="${this.onLinkClick}"
+ @keydown="${this.onLinkKeyDown}"
+ aria-label="Link to check details"
><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
></a>
`
@@ -484,19 +525,34 @@
);
if (sum === 0) return;
return html`<gr-checks-chip
- class="${icon}"
- .icon="${icon}"
+ .statusOrCategory="${statusOrCategory}"
.text="${sum}"
@click="${() => this.onChipClick({statusOrCategory})}"
+ @keydown="${(e: KeyboardEvent) =>
+ this.onChipKeyDown(e, {statusOrCategory})}"
></gr-checks-chip>`;
}
+ private onChipKeyDown(e: KeyboardEvent, state: ChecksTabState) {
+ if (modifierPressed(e)) return;
+ // Only react to `return` and `space`.
+ if (e.keyCode !== 13 && e.keyCode !== 32) return;
+ e.preventDefault();
+ e.stopPropagation();
+ this.onChipClick(state);
+ }
+
private onChipClick(state: ChecksTabState) {
fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
checksTab: state,
});
}
+ private onLinkKeyDown(e: KeyboardEvent) {
+ // Prevents onConChipKeyDown() from reacting to <a> link keyboard events.
+ e.stopPropagation();
+ }
+
private onLinkClick(e: MouseEvent) {
// Prevents onChipClick() from reacting to <a> link clicks.
e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 4304734..2ede186e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1254,16 +1254,37 @@
this.$.fileList.collapseAllDiffs();
}
+ /**
+ * ChangeView is never re-used for different changes. It is safer and simpler
+ * to just re-create another change view when the user switches to a new
+ * change page. Thus we need a reliable way to detect that the change view
+ * does not match the current change number anymore.
+ *
+ * If this method returns true, then the change view should not do anything
+ * anymore. The app element makes sure that an obsolete change view is not
+ * shown anymore, so if the change view is still and doing some update to
+ * itself, then that is not dangerous. But for example it should not call
+ * navigateToChange() anymore. That would very likely cause erroneous
+ * behavior.
+ */
+ private isChangeObsolete() {
+ // While this._changeNum is undefined the change view is fresh and has just
+ // not updated it to params.changeNum yet. Not obsolete in that case.
+ if (this._changeNum === undefined) return false;
+ // this.params reflects the current state of the URL. If this._changeNum
+ // does not match it anymore, then this view must be considered obsolete.
+ return this._changeNum !== this.params?.changeNum;
+ }
+
_paramsChanged(value: AppElementChangeViewParams) {
if (value.view !== GerritView.CHANGE) {
this._initialLoadComplete = false;
return;
}
- // Everything in the change view is tied to the change. It seems better to
- // force the re-creation of the change view when the change number changes.
- const changeChanged = this._changeNum !== value.changeNum;
- if (this._changeNum !== undefined && changeChanged) {
+ if (this.isChangeObsolete()) {
+ // Tell the app element that we are not going to handle the new change
+ // number and that they have to create a new change view.
fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
return;
}
@@ -1298,7 +1319,7 @@
// If the change has already been loaded and the parameter change is only
// in the patch range, then don't do a full reload.
- if (!changeChanged && patchChanged && patchKnown) {
+ if (this._changeNum !== undefined && patchChanged && patchKnown) {
if (!patchRange.patchNum) {
patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
rightPatchNumChanged = true;
@@ -2116,6 +2137,7 @@
* promise resolves.
*/
loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
+ if (this.isChangeObsolete()) return Promise.resolve([]);
if (clearPatchset && this._change) {
GerritNav.navigateToChange(this._change);
return Promise.resolve([]);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 5128948..7d47abb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -1614,7 +1614,7 @@
});
});
- test('don’t reload entire page when patchRange changes', () => {
+ test('don’t reload entire page when patchRange changes', async () => {
const reloadStub = sinon
.stub(element, 'loadData')
.callsFake(() => Promise.resolve([]));
@@ -1629,7 +1629,8 @@
view: GerritView.CHANGE,
patchNum: 1 as RevisionPatchSetNum,
};
- element._paramsChanged(value);
+ element.params = value;
+ await flush();
assert.isTrue(reloadStub.calledOnce);
element._initialLoadComplete = true;
@@ -1643,13 +1644,14 @@
value.basePatchNum = 1 as BasePatchSetNum;
value.patchNum = 2 as RevisionPatchSetNum;
- element._paramsChanged(value);
+ element.params = {...value};
+ await flush();
assert.isFalse(reloadStub.calledTwice);
assert.isTrue(reloadPatchDependentStub.calledOnce);
assert.isTrue(collapseStub.calledTwice);
});
- test('reload ported comments when patchNum changes', () => {
+ test('reload ported comments when patchNum changes', async () => {
sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
sinon.stub(element, '_getCommitInfo');
sinon.stub(element.$.fileList, 'reload');
@@ -1665,7 +1667,8 @@
view: GerritView.CHANGE,
patchNum: 1 as RevisionPatchSetNum,
};
- element._paramsChanged(value);
+ element.params = value;
+ await flush();
element._initialLoadComplete = true;
element._change = {
@@ -1678,24 +1681,42 @@
value.basePatchNum = 1 as BasePatchSetNum;
value.patchNum = 2 as RevisionPatchSetNum;
- element._paramsChanged(value);
+ element.params = {...value};
+ await flush();
assert.isTrue(reloadPortedCommentsStub.calledOnce);
});
- test('reload entire page when patchRange doesnt change', () => {
+ test('reload entire page when patchRange doesnt change', async () => {
const reloadStub = sinon
.stub(element, 'loadData')
.callsFake(() => Promise.resolve([]));
const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
const value: AppElementChangeViewParams = createAppElementChangeViewParams();
- element._paramsChanged(value);
+ element.params = value;
+ await flush();
assert.isTrue(reloadStub.calledOnce);
element._initialLoadComplete = true;
- element._paramsChanged(value);
+ element.params = {...value};
+ await flush();
assert.isTrue(reloadStub.calledTwice);
assert.isTrue(collapseStub.calledTwice);
});
+ test('do not handle new change numbers', async () => {
+ const recreateSpy = sinon.spy();
+ element.addEventListener('recreate-change-view', recreateSpy);
+
+ const value: AppElementChangeViewParams = createAppElementChangeViewParams();
+ element.params = value;
+ await flush();
+ assert.isFalse(recreateSpy.calledOnce);
+
+ value.changeNum = 555111333 as NumericChangeId;
+ element.params = {...value};
+ await flush();
+ assert.isTrue(recreateSpy.calledOnce);
+ });
+
test('related changes are not updated after other action', done => {
sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
flush();
@@ -2026,7 +2047,7 @@
element.handleScroll();
});
- test('scrollTop is set correctly', () => {
+ test('scrollTop is set correctly', async () => {
element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
sinon.stub(element, 'loadData').callsFake(() => {
@@ -2039,7 +2060,8 @@
// simulate reloading component, which is done when route
// changes to match a regex of change view type.
- element._paramsChanged({...createAppElementChangeViewParams()});
+ element.params = {...createAppElementChangeViewParams()};
+ await flush();
});
test('scrollTop is reset when new change is loaded', () => {
@@ -2591,7 +2613,7 @@
});
});
- test('report changeDisplayed on _paramsChanged', done => {
+ test('report changeDisplayed on _paramsChanged', async () => {
const changeDisplayStub = sinon.stub(
appContext.reportingService,
'changeDisplayed'
@@ -2600,16 +2622,14 @@
appContext.reportingService,
'changeFullyLoaded'
);
- element._paramsChanged({
+ element.params = {
...createAppElementChangeViewParams(),
changeNum: TEST_NUMERIC_CHANGE_ID,
project: TEST_PROJECT_NAME,
- });
- flush(() => {
- assert.isTrue(changeDisplayStub.called);
- assert.isTrue(changeFullyLoadedStub.called);
- done();
- });
+ };
+ await flush();
+ assert.isTrue(changeDisplayStub.called);
+ assert.isTrue(changeFullyLoadedStub.called);
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 8283be79..2e8e4ba 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -17,18 +17,20 @@
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 {customElement, observe, property} from '@polymer/decorators';
+import {computed, customElement, observe, property} from '@polymer/decorators';
import {
PolymerSpliceChange,
PolymerDeepPropertyChange,
} from '@polymer/polymer/interfaces';
-import {ChangeInfo} from '../../../types/common';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
import {
CommentThread,
isDraft,
@@ -36,11 +38,14 @@
isDraftThread,
isRobotThread,
hasHumanReply,
+ getCommentAuthors,
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
import {fireThreadListModifiedEvent} from '../../../utils/event-util';
-import {assertNever} from '../../../utils/common-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;
@@ -52,6 +57,13 @@
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() {
@@ -79,7 +91,7 @@
@property({
computed:
'_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
- '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+ '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
type: Array,
})
_displayedThreads: CommentThread[] = [];
@@ -102,6 +114,21 @@
@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[],
@@ -146,7 +173,74 @@
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
@@ -202,6 +296,15 @@
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:
@@ -254,12 +357,7 @@
return;
}
- const threadsWithInfo = threads.map(thread =>
- this._getThreadWithStatusInfo(thread)
- );
- this._sortedThreads = threadsWithInfo
- .sort((t1, t2) => this._compareThreads(t1, t2))
- .map(threadInfo => threadInfo.thread);
+ this.resortThreads(threads);
}
_computeDisplayedThreads(
@@ -269,7 +367,8 @@
>,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean
+ onlyShowRobotCommentsWithHumanReply?: boolean,
+ selectedAuthors?: AccountInfo[]
) {
if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
return sortedThreadsRecord.base.filter(t =>
@@ -277,7 +376,8 @@
t,
unresolvedOnly,
draftsOnly,
- onlyShowRobotCommentsWithHumanReply
+ onlyShowRobotCommentsWithHumanReply,
+ selectedAuthors
)
);
}
@@ -287,14 +387,16 @@
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean
+ onlyShowRobotCommentsWithHumanReply?: boolean,
+ selectedAuthors?: AccountInfo[]
) {
const threads = displayedThreads.filter(t =>
this._shouldShowThread(
t,
unresolvedOnly,
draftsOnly,
- onlyShowRobotCommentsWithHumanReply
+ onlyShowRobotCommentsWithHumanReply,
+ selectedAuthors
)
);
const index = threads.findIndex(t => t.rootId === thread.rootId);
@@ -309,14 +411,16 @@
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean
+ onlyShowRobotCommentsWithHumanReply?: boolean,
+ selectedAuthors?: AccountInfo[]
) {
const threads = displayedThreads.filter(t =>
this._shouldShowThread(
t,
unresolvedOnly,
draftsOnly,
- onlyShowRobotCommentsWithHumanReply
+ onlyShowRobotCommentsWithHumanReply,
+ selectedAuthors
)
);
const index = threads.findIndex(t => t.rootId === thread.rootId);
@@ -330,7 +434,8 @@
thread,
unresolvedOnly,
draftsOnly,
- onlyShowRobotCommentsWithHumanReply
+ onlyShowRobotCommentsWithHumanReply,
+ selectedAuthors
)
);
}
@@ -339,7 +444,8 @@
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean
+ onlyShowRobotCommentsWithHumanReply?: boolean,
+ selectedAuthors?: AccountInfo[]
) {
if (
[
@@ -347,11 +453,26 @@
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 &&
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index da91095..41beed2 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -33,7 +33,7 @@
border-top: 1px solid var(--border-color);
display: flex;
justify-content: left;
- padding: var(--spacing-m) var(--spacing-l);
+ padding: var(--spacing-s) var(--spacing-l);
}
.draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
.unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
@@ -48,54 +48,70 @@
box-shadow: none;
padding-left: var(--spacing-m);
}
- .header .categoryRadio {
- height: 18px;
- width: 18px;
- }
- .header label {
- padding-left: 8px;
- margin-right: 16px;
- }
.partypopper{
margin-right: var(--spacing-s);
}
+ gr-dropdown-list {
+ --trigger-style: {
+ color: var(--primary-text-color);
+ text-transform: none;
+ font-family: var(--font-family);
+ }
+ }
+ .filter-text, .sort-text, .author-text {
+ margin-right: var(--spacing-s);
+ color: var(--deemphasized-text-color);
+ }
+ .author-text {
+ margin-left: var(--spacing-m);
+ }
+ gr-account-label {
+ --account-max-length: 120px;
+ display: inline-block;
+ user-select: none;
+ --label-border-radius: 8px;
+ margin: 0 var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-m);
+ line-height: var(--line-height-normal);
+ cursor: pointer;
+ }
+ gr-account-label:focus {
+ outline: none;
+ }
+ gr-account-label:hover,
+ gr-account-label:hover {
+ box-shadow: var(--elevation-level-1);
+ cursor: pointer;
+ }
</style>
<template is="dom-if" if="[[!hideToggleButtons]]">
<div class="header">
- <input
- class="categoryRadio"
- id="unresolvedRadio"
- name="filterComments"
- type="radio"
- on-click="_handleOnlyUnresolved"
- checked="[[unresolvedOnly]]"
- />
- <label for="unresolvedRadio">
- Unresolved ([[_countUnresolved(threads)]])
- </label>
- <input
- class="categoryRadio"
- id="draftsRadio"
- name="filterComments"
- type="radio"
- on-click="_handleOnlyDrafts"
- checked="[[_draftsOnly]]"
- hidden$="[[!loggedIn]]"
- />
- <label for="draftsRadio" hidden$="[[!loggedIn]]">
- Drafts ([[_countDrafts(threads)]])
- </label>
- <input
- class="categoryRadio"
- id="allRadio"
- name="filterComments"
- type="radio"
- on-click="_handleAllComments"
- checked="[[_showAllComments(_draftsOnly, unresolvedOnly)]]"
- />
- <label for="allRadio">
- All ([[_countAllThreads(threads)]])
- </label>
+ <span class="sort-text">Sort By:</span>
+ <gr-dropdown-list
+ id="sortDropdown"
+ value="[[sortDropdownValue]]"
+ on-value-change="handleSortDropdownValueChange"
+ items="[[getSortDropdownEntires()]]"
+ >
+ </gr-dropdown-list>
+ <span class="separator"></span>
+ <span class="filter-text">Filter By:</span>
+ <gr-dropdown-list
+ id="filterDropdown"
+ value="[[commentsDropdownValue]]"
+ on-value-change="handleCommentsDropdownValueChange"
+ items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
+ >
+ </gr-dropdown-list>
+ <span class="author-text">From:</span>
+ <template is="dom-repeat" items="[[getCommentAuthors(threads)]]">
+ <gr-account-label
+ account="[[item]]"
+ on-click="handleAccountClicked"
+ selection-chip-style
+ selected="[[isSelected(item, selectedAuthors)]]"
+ > </gr-account-label>
+ </template>
</div>
</template>
<div id="threads">
@@ -131,7 +147,7 @@
>
<template
is="dom-if"
- if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+ if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
>
<div class="thread-separator"></div>
</template>
@@ -142,7 +158,7 @@
change-num="[[changeNum]]"
comments="[[thread.comments]]"
diff-side="[[thread.diffSide]]"
- show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+ show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
project-name="[[change.project]]"
is-on-parent="[[_isOnParent(thread.commentSide)]]"
line-num="[[thread.line]]"
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index 3d6cfd4..5c87c4b 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -19,6 +19,11 @@
import './gr-thread-list.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {SpecialFilePath} from '../../../constants/constants.js';
+import {CommentTabState} from '../../../types/events.js';
+import {__testOnly_SortDropdownState} from './gr-thread-list.js';
+import {queryAll} from '../../../test/test-utils.js';
+import {accountOrGroupKey} from '../../../utils/account-util.js';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
const basicFixture = fixtureFromElement('gr-thread-list');
@@ -45,14 +50,14 @@
{
path: '/COMMIT_MSG',
author: {
- _account_id: 1000000,
+ _account_id: 1000001,
name: 'user',
username: 'user',
},
patch_set: 4,
id: 'ecf0b9fa_fe1a5f62',
line: 5,
- updated: '2018-02-08 18:49:18.000000000',
+ updated: '1',
message: 'test',
unresolved: true,
},
@@ -61,7 +66,7 @@
path: '/COMMIT_MSG',
line: 5,
in_reply_to: 'ecf0b9fa_fe1a5f62',
- updated: '2018-02-13 22:48:48.018000000',
+ updated: '1',
message: 'draft',
unresolved: true,
__draft: true,
@@ -74,21 +79,21 @@
path: '/COMMIT_MSG',
line: 5,
rootId: 'ecf0b9fa_fe1a5f62',
- start_datetime: '2018-02-08 18:49:18.000000000',
+ updated: '1',
},
{
comments: [
{
path: 'test.txt',
author: {
- _account_id: 1000000,
+ _account_id: 1000002,
name: 'user',
username: 'user',
},
patch_set: 3,
id: '09a9fb0a_1484e6cf',
side: 'PARENT',
- updated: '2018-02-13 22:47:19.000000000',
+ updated: '2',
message: 'Some comment on another patchset.',
unresolved: false,
},
@@ -96,7 +101,7 @@
patchNum: 3,
path: 'test.txt',
rootId: '09a9fb0a_1484e6cf',
- start_datetime: '2018-02-13 22:47:19.000000000',
+ updated: '2',
commentSide: 'PARENT',
},
{
@@ -104,13 +109,13 @@
{
path: '/COMMIT_MSG',
author: {
- _account_id: 1000000,
+ _account_id: 1000002,
name: 'user',
username: 'user',
},
patch_set: 2,
id: '8caddf38_44770ec1',
- updated: '2018-02-13 22:48:40.000000000',
+ updated: '3',
message: 'Another unresolved comment',
unresolved: false,
},
@@ -118,21 +123,21 @@
patchNum: 2,
path: '/COMMIT_MSG',
rootId: '8caddf38_44770ec1',
- start_datetime: '2018-02-13 22:48:40.000000000',
+ updated: '3',
},
{
comments: [
{
path: '/COMMIT_MSG',
author: {
- _account_id: 1000000,
+ _account_id: 1000003,
name: 'user',
username: 'user',
},
patch_set: 2,
id: 'scaddf38_44770ec1',
line: 4,
- updated: '2018-02-14 22:48:40.000000000',
+ updated: '4',
message: 'Yet another unresolved comment',
unresolved: true,
},
@@ -141,7 +146,7 @@
path: '/COMMIT_MSG',
line: 4,
rootId: 'scaddf38_44770ec1',
- start_datetime: '2018-02-14 22:48:40.000000000',
+ updated: '4',
},
{
comments: [
@@ -149,7 +154,7 @@
id: 'zcf0b9fa_fe1a5f62',
path: '/COMMIT_MSG',
line: 6,
- updated: '2018-02-15 22:48:48.018000000',
+ updated: '5',
message: 'resolved draft',
unresolved: false,
__draft: true,
@@ -162,14 +167,14 @@
path: '/COMMIT_MSG',
line: 6,
rootId: 'zcf0b9fa_fe1a5f62',
- start_datetime: '2018-02-09 18:49:18.000000000',
+ updated: '5',
},
{
comments: [
{
id: 'patchset_level_1',
path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- updated: '2018-02-15 22:48:48.018000000',
+ updated: '6',
message: 'patchset comment 1',
unresolved: false,
__editing: false,
@@ -179,14 +184,14 @@
patchNum: 2,
path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
rootId: 'patchset_level_1',
- start_datetime: '2018-02-09 18:49:18.000000000',
+ updated: '6',
},
{
comments: [
{
id: 'patchset_level_2',
path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- updated: '2018-02-15 22:48:48.018000000',
+ updated: '7',
message: 'patchset comment 2',
unresolved: false,
__editing: false,
@@ -196,7 +201,7 @@
patchNum: 3,
path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
rootId: 'patchset_level_2',
- start_datetime: '2018-02-09 18:49:18.000000000',
+ updated: '7',
},
{
comments: [
@@ -210,7 +215,7 @@
patch_set: 4,
id: 'rc1',
line: 5,
- updated: '2019-02-08 18:49:18.000000000',
+ updated: '8',
message: 'test',
unresolved: true,
robot_id: 'rc1',
@@ -220,7 +225,7 @@
path: '/COMMIT_MSG',
line: 5,
rootId: 'rc1',
- start_datetime: '2019-02-08 18:49:18.000000000',
+ updated: '8',
},
{
comments: [
@@ -234,7 +239,7 @@
patch_set: 4,
id: 'rc2',
line: 7,
- updated: '2019-03-08 18:49:18.000000000',
+ updated: '9',
message: 'test',
unresolved: true,
robot_id: 'rc2',
@@ -249,7 +254,7 @@
patch_set: 4,
id: 'c2_1',
line: 5,
- updated: '2019-03-08 18:49:18.000000000',
+ updated: '10',
message: 'test',
unresolved: true,
},
@@ -258,7 +263,7 @@
path: '/COMMIT_MSG',
line: 7,
rootId: 'rc2',
- start_datetime: '2019-03-08 18:49:18.000000000',
+ updated: '10',
},
];
@@ -270,15 +275,15 @@
});
});
- test('draft toggle only appears when logged in', () => {
+ test('draft dropdown item only appears when logged in', () => {
element.loggedIn = false;
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('#draftsRadio')).display,
- 'none');
+ flush();
+ assert.equal(element.getCommentsDropdownEntires(element.threads,
+ element.loggedIn).length, 2);
element.loggedIn = true;
- assert.notEqual(getComputedStyle(element.shadowRoot
- .querySelector('#draftsRadio')).display,
- 'none');
+ flush();
+ assert.equal(element.getCommentsDropdownEntires(element.threads,
+ element.loggedIn).length, 3);
});
test('show all threads by default', () => {
@@ -298,13 +303,16 @@
});
test('showing file name takes visible threads into account', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
- element.onlyShowRobotCommentsWithHumanReply), true);
+ element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
+ true);
element.unresolvedOnly = true;
assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
- element.onlyShowRobotCommentsWithHumanReply), false);
+ element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
+ false);
});
test('onlyShowRobotCommentsWithHumanReply ', () => {
@@ -317,6 +325,10 @@
});
suite('_compareThreads', () => {
+ setup(() => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+ });
+
test('patchset comes before any other file', () => {
const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
@@ -448,6 +460,7 @@
});
test('_computeSortedThreads', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
assert.equal(element._sortedThreads.length, 9);
const expectedSortedRootIds = [
'patchset_level_2', // Posted on Patchset 3
@@ -465,7 +478,68 @@
});
});
+ test('_computeSortedThreads with timestamp', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+ element.resortThreads(element.threads);
+ assert.equal(element._sortedThreads.length, 9);
+ const expectedSortedRootIds = [
+ 'rc2',
+ 'rc1',
+ 'patchset_level_2',
+ 'patchset_level_1',
+ 'zcf0b9fa_fe1a5f62',
+ 'scaddf38_44770ec1',
+ '8caddf38_44770ec1',
+ '09a9fb0a_1484e6cf',
+ 'ecf0b9fa_fe1a5f62',
+ ];
+ element._sortedThreads.forEach((thread, index) => {
+ assert.equal(thread.rootId, expectedSortedRootIds[index]);
+ });
+ });
+
+ test('tapping single author chips', () => {
+ const chips = queryAll(element, 'gr-account-label');
+ const authors = Array.from(chips).map(
+ chip => accountOrGroupKey(chip.account))
+ .sort();
+ assert.deepEqual(authors, [1000000, 1000001, 1000002, 1000003]);
+ assert.equal(element.threads.length, 9);
+ assert.equal(element._displayedThreads.length, 9);
+
+ tap(chips[0]); // accountId 1000001
+ flush();
+
+ assert.equal(element.threads.length, 9);
+ assert.equal(element._displayedThreads.length, 1);
+ assert.equal(element._displayedThreads[0].comments[0].author._account_id,
+ 1000001);
+
+ tap(chips[0]); // tapping again resets
+ flush();
+ assert.equal(element.threads.length, 9);
+ assert.equal(element._displayedThreads.length, 9);
+ });
+
+ test('tapping multiple author chips', () => {
+ const chips = queryAll(element, 'gr-account-label');
+
+ tap(chips[0]); // accountId 1000001
+ tap(chips[1]); // accountId 1000002
+ flush();
+
+ assert.equal(element.threads.length, 9);
+ assert.equal(element._displayedThreads.length, 3);
+ assert.equal(element._displayedThreads[0].comments[0].author._account_id,
+ 1000002);
+ assert.equal(element._displayedThreads[1].comments[0].author._account_id,
+ 1000002);
+ assert.equal(element._displayedThreads[2].comments[0].author._account_id,
+ 1000001);
+ });
+
test('thread removal and sort again', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
threadElements[1].dispatchEvent(
new CustomEvent('thread-discard', {
detail: {rootId: 'rc2'},
@@ -489,6 +563,7 @@
});
test('modification on thread shold not trigger sort again', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
const currentSortedThreads = [...element._sortedThreads];
for (const thread of currentSortedThreads) {
thread.comments = [...thread.comments];
@@ -526,6 +601,7 @@
test('non-equal length of sortThreads and threads' +
' should trigger sort again', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
const modifiedThreads = [...element.threads];
const currentSortedThreads = [...element._sortedThreads];
element._sortedThreads = [];
@@ -549,22 +625,23 @@
});
});
- test('toggle all shows all all comments', () => {
- MockInteractions.tap(element.shadowRoot.querySelector(
- '#allRadio'));
+ test('show all comments', () => {
+ element.handleCommentsDropdownValueChange({detail: {
+ value: CommentTabState.SHOW_ALL}});
flush();
assert.equal(getVisibleThreads().length, 9);
});
- test('toggle unresolved shows all unresolved comments', () => {
- MockInteractions.tap(element.shadowRoot.querySelector(
- '#unresolvedRadio'));
+ test('unresolved shows all unresolved comments', () => {
+ element.handleCommentsDropdownValueChange({detail: {
+ value: CommentTabState.UNRESOLVED}});
flush();
assert.equal(getVisibleThreads().length, 4);
});
test('toggle drafts only shows threads with draft comments', () => {
- MockInteractions.tap(element.shadowRoot.querySelector('#draftsRadio'));
+ element.handleCommentsDropdownValueChange({detail: {
+ value: CommentTabState.DRAFTS}});
flush();
assert.equal(getVisibleThreads().length, 2);
});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 2ea6dd0..fd8cd80 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -52,7 +52,7 @@
fireActionTriggered,
firstPrimaryLink,
hasCompletedWithoutResults,
- iconForCategory,
+ iconFor,
iconForLink,
otherPrimaryLinks,
primaryRunAction,
@@ -60,7 +60,7 @@
tooltipForLink,
} from '../../services/checks/checks-util';
import {assertIsDefined, check} from '../../utils/common-util';
-import {toggleClass, whenVisible} from '../../utils/dom-util';
+import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
import {durationString} from '../../utils/date-util';
import {charsOnly} from '../../utils/string-util';
import {isAttemptSelected} from './gr-checks-util';
@@ -83,6 +83,9 @@
@customElement('gr-result-row')
class GrResultRow extends GrLitElement {
+ @query('td.nameCol div.name')
+ nameEl?: HTMLElement;
+
@property()
result?: RunResult;
@@ -119,9 +122,11 @@
tr.container {
border-top: 1px solid var(--border-color);
}
+ a.link {
+ margin-right: var(--spacing-s);
+ }
iron-icon.link {
color: var(--link-color);
- margin-right: var(--spacing-s);
}
td.nameCol div.flex {
display: flex;
@@ -130,6 +135,7 @@
overflow: hidden;
text-overflow: ellipsis;
margin-right: var(--spacing-s);
+ outline-offset: var(--spacing-xs);
}
td.nameCol .space {
flex-grow: 1;
@@ -137,6 +143,7 @@
td.nameCol gr-checks-action {
display: none;
}
+ tr:focus-within td.nameCol gr-checks-action,
tr:hover td.nameCol gr-checks-action {
display: inline-block;
/* The button should fit into the 20px line-height. The negative
@@ -180,8 +187,13 @@
tr.container:hover {
background: var(--hover-background-color);
}
+ tr.container:focus-within {
+ background: var(--selection-background-color);
+ }
tr.container td .summary-cell .links,
tr.container td .summary-cell .actions,
+ tr.container.collapsed:focus-within td .summary-cell .links,
+ tr.container.collapsed:focus-within td .summary-cell .actions,
tr.container.collapsed:hover td .summary-cell .links,
tr.container.collapsed:hover td .summary-cell .actions,
:host(.dropdown-open) tr td .summary-cell .links,
@@ -278,6 +290,10 @@
super.update(changedProperties);
}
+ focus() {
+ if (this.nameEl) this.nameEl.focus();
+ }
+
firstUpdated() {
const loading = this.shadowRoot?.querySelector('.container');
assertIsDefined(loading, '"Loading" element');
@@ -299,10 +315,18 @@
}
return html`
<tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
- <td class="nameCol" @click="${this.toggleExpanded}">
+ <td class="nameCol" @click="${this.toggleExpandedClick}">
<div class="flex">
<gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
- <div class="name">${this.result.checkName}</div>
+ <div
+ class="name"
+ role="button"
+ tabindex="0"
+ @click="${this.toggleExpandedClick}"
+ @keydown="${this.toggleExpandedPress}"
+ >
+ ${this.result.checkName}
+ </div>
<div class="space"></div>
${this.renderPrimaryRunAction()}
</div>
@@ -311,7 +335,7 @@
<div class="summary-cell">
${this.renderLink(firstPrimaryLink(this.result))}
${this.renderSummary(this.result.summary)}
- <div class="message" @click="${this.toggleExpanded}">
+ <div class="message" @click="${this.toggleExpandedClick}">
${this.isExpanded ? '' : this.result.message}
</div>
${this.renderLinks()} ${this.renderActions()}
@@ -321,7 +345,7 @@
${this.renderLabel()}
</div>
</td>
- <td class="expanderCol" @click="${this.toggleExpanded}">
+ <td class="expanderCol" @click="${this.toggleExpandedClick}">
<div
class="show-hide"
role="switch"
@@ -331,7 +355,7 @@
aria-label="${this.isExpanded
? 'Collapse result row'
: 'Expand result row'}"
- @keydown="${this.toggleExpanded}"
+ @keydown="${this.toggleExpandedPress}"
>
<iron-icon
icon="${this.isExpanded
@@ -361,6 +385,23 @@
></gr-result-expanded>`;
}
+ private toggleExpandedClick(e: MouseEvent) {
+ if (!this.isExpandable) return;
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleExpanded();
+ }
+
+ private toggleExpandedPress(e: KeyboardEvent) {
+ if (!this.isExpandable) return;
+ if (modifierPressed(e)) return;
+ // Only react to `return` and `space`.
+ if (e.keyCode !== 13 && e.keyCode !== 32) return;
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleExpanded();
+ }
+
private toggleExpanded() {
if (!this.isExpandable) return;
this.isExpanded = !this.isExpanded;
@@ -383,10 +424,14 @@
if (!this.result?.isLatestAttempt) return;
const info = this.labels?.[label];
const status = getLabelStatus(info).toLowerCase();
- const value = valueString(getRepresentativeValue(info));
+ const value = getRepresentativeValue(info);
+ // A neutral vote is not interesting for the user to see and is just
+ // cluttering the UI.
+ if (value === 0) return;
+ const valueStr = valueString(value);
return html`
<div class="label ${status}">
- <span>${label} ${value}</span>
+ <span>${label} ${valueStr}</span>
<paper-tooltip offset="5" fit-to-visible-bounds="true">
The check result has (probably) influenced this label vote.
</paper-tooltip>
@@ -414,7 +459,7 @@
if (this.isExpanded) return;
if (!link) return;
const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
- return html`<a href="${link.url}" target="_blank"
+ return html`<a href="${link.url}" class="link" target="_blank"
><iron-icon
aria-label="external link to details"
class="link"
@@ -897,7 +942,7 @@
) {
let cat = statusOrCategory.toString().toLowerCase();
if (statusOrCategory === RunStatus.COMPLETED) cat = 'success';
- this.scrollElIntoView(`.categoryHeader .${cat}`);
+ this.scrollElIntoView(`.categoryHeader.${cat} + table gr-result-row`);
} else if (checkName) {
this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`);
}
@@ -910,6 +955,7 @@
// el might be a <gr-result-row> with an empty shadowRoot. Let's wait a
// moment before trying to find a child element in it.
setTimeout(() => {
+ if (el) (el as HTMLElement).focus();
// <gr-result-row> has display:contents and cannot be scrolled into view
// itself. Thus we are preferring to scroll the first child into view.
el = el?.shadowRoot?.firstElementChild ?? el;
@@ -1184,7 +1230,7 @@
<iron-icon class="expandIcon" icon="${icon}"></iron-icon>
<div class="statusIconWrapper">
<iron-icon
- icon="gr-icons:${iconForCategory(category)}"
+ icon="gr-icons:${iconFor(category)}"
class="statusIcon ${catString}"
></iron-icon>
<span class="title">${catString}</span>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 4f9fd1d..0cf47cd 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -33,7 +33,7 @@
AttemptDetail,
compareByWorstCategory,
fireActionTriggered,
- iconForCategory,
+ iconFor,
iconForRun,
PRIMARY_STATUS_ACTIONS,
primaryRunAction,
@@ -317,7 +317,7 @@
if (this.run.status !== RunStatus.RUNNING) return nothing;
const category = worstCategory(this.run);
if (!category) return nothing;
- const icon = iconForCategory(category);
+ const icon = iconFor(category);
return html`
<iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
`;
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 59b9de8..9d50b1c 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -22,8 +22,7 @@
import './gr-checks-action';
import {CheckRun} from '../../services/checks/checks-model';
import {
- iconForCategory,
- iconForStatus,
+ iconFor,
runActions,
worstCategory,
} from '../../services/checks/checks-util';
@@ -43,9 +42,9 @@
computeIcon(run?: CheckRun) {
if (!run) return '';
const category = worstCategory(run);
- if (category) return iconForCategory(category);
+ if (category) return iconFor(category);
return run.status === RunStatus.COMPLETED
- ? iconForStatus(RunStatus.COMPLETED)
+ ? iconFor(RunStatus.COMPLETED)
: '';
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 39d3c8b..087779e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -19,6 +19,8 @@
ChecksApiConfig,
ChecksProvider,
ChecksPluginApi,
+ CheckResult,
+ CheckRun,
} from '../../../api/checks';
import {appContext} from '../../../services/app-context';
@@ -54,6 +56,13 @@
this.checksService.reload(this.plugin.getPluginName());
}
+ updateResult(run: CheckRun, result: CheckResult) {
+ if (result.externalId === undefined) {
+ throw new Error('ChecksApi.updateResult() was called without externalId');
+ }
+ this.checksService.updateResult(this.plugin.getPluginName(), run, result);
+ }
+
register(provider: ChecksProvider, config?: ChecksApiConfig): void {
this.reporting.trackApi(this.plugin, 'checks', 'register');
if (this.state === State.REGISTERED)
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 8968e18..519c24d 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -148,8 +148,10 @@
<g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
<!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
<g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
- <!-- This SVG is a copy from material.io https://material.io/icons/#code-->
+ <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Acode-->
<g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
+ <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Afile_present-->
+ <g id="file-present"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7l-5-5zM6 20V4h8v4h4v12H6zm10-10v5c0 2.21-1.79 4-4 4s-4-1.79-4-4V8.5c0-1.47 1.26-2.64 2.76-2.49 1.3.13 2.24 1.32 2.24 2.63V15h-2V8.5c0-.28-.22-.5-.5-.5s-.5.22-.5.5V15c0 1.1.9 2 2 2s2-.9 2-2v-5h2z"/></g>
</defs>
</svg>
</iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 570d80a..3a0bbf2 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -114,7 +114,20 @@
pluginStateSelected: {},
};
-const privateState$ = new BehaviorSubject(initialState);
+// Mutable for testing
+let privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+ privateState$ = new BehaviorSubject(initialState);
+}
+
+export function _testOnly_setState(state: ChecksState) {
+ privateState$.next(state);
+}
+
+export function _testOnly_getState() {
+ return privateState$.getValue();
+}
// Re-exporting as Observable so that you can only subscribe, but not emit.
export const checksState$: Observable<ChecksState> = privateState$;
@@ -533,6 +546,14 @@
internalResultId: 'f44r0',
category: Category.INFO,
summary: 'Dont be afraid. All TODOs will be eliminated.',
+ actions: [
+ {
+ name: 'Re-Run',
+ tooltip: 'More powerful run than before with a long tooltip, really.',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+ },
+ ],
},
],
};
@@ -711,6 +732,42 @@
privateState$.next(nextState);
}
+export function updateStateUpdateResult(
+ pluginName: string,
+ updatedRun: CheckRunApi,
+ updatedResult: CheckResultApi,
+ patchset: ChecksPatchset
+) {
+ const nextState = {...privateState$.getValue()};
+ const pluginState = getPluginState(nextState, patchset);
+ let runUpdated = false;
+ const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
+ if (run.change !== updatedRun.change) return run;
+ if (run.patchset !== updatedRun.patchset) return run;
+ if (run.attempt !== updatedRun.attempt) return run;
+ if (run.checkName !== updatedRun.checkName) return run;
+ let resultUpdated = false;
+ const results: CheckResult[] = (run.results ?? []).map(result => {
+ if (result.externalId && result.externalId === updatedResult.externalId) {
+ runUpdated = true;
+ resultUpdated = true;
+ return {
+ ...updatedResult,
+ internalResultId: result.internalResultId,
+ };
+ }
+ return result;
+ });
+ return resultUpdated ? {...run, results} : run;
+ });
+ if (!runUpdated) return;
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ runs,
+ };
+ privateState$.next(nextState);
+}
+
export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
const nextState = {...privateState$.getValue()};
nextState.patchsetNumberSelected = patchsetNumber;
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
new file mode 100644
index 0000000..f05facb
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2021 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 '../../test/common-test-setup-karma';
+import './checks-model';
+import {
+ _testOnly_getState,
+ _testOnly_resetState,
+ ChecksPatchset,
+ updateStateSetProvider,
+ updateStateSetResults,
+ updateStateUpdateResult,
+} from './checks-model';
+import {Category, CheckRun, RunStatus} from '../../api/checks';
+
+const PLUGIN_NAME = 'test-plugin';
+
+const RUNS: CheckRun[] = [
+ {
+ checkName: 'MacCheck',
+ change: 123,
+ patchset: 1,
+ attempt: 1,
+ status: RunStatus.COMPLETED,
+ results: [
+ {
+ externalId: 'id-314',
+ category: Category.WARNING,
+ summary: 'Meddle cheddle check and you are weg.',
+ },
+ ],
+ },
+];
+
+suite('checks-model tests', () => {
+ test('updateStateSetProvider', () => {
+ _testOnly_resetState();
+ updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+ const state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+ assert.deepEqual(state, {
+ pluginName: PLUGIN_NAME,
+ loading: false,
+ runs: [],
+ actions: [],
+ links: [],
+ });
+ });
+
+ test('updateStateSetResults', () => {
+ _testOnly_resetState();
+ updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+ const state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+ assert.lengthOf(state.runs, 1);
+ assert.lengthOf(state.runs[0].results!, 1);
+ });
+
+ test('updateStateUpdateResult', () => {
+ _testOnly_resetState();
+ updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+ let state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+ assert.equal(
+ state.runs[0].results![0].summary,
+ RUNS[0]!.results![0].summary
+ );
+ const result = RUNS[0].results![0];
+ const updatedResult = {...result, summary: 'new'};
+ updateStateUpdateResult(
+ PLUGIN_NAME,
+ RUNS[0],
+ updatedResult,
+ ChecksPatchset.LATEST
+ );
+ state = _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+ assert.lengthOf(state.runs, 1);
+ assert.lengthOf(state.runs[0].results!, 1);
+ assert.equal(state.runs[0].results![0].summary, 'new');
+ });
+});
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index bc05484..7844fa6 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -25,6 +25,8 @@
} from 'rxjs/operators';
import {
ChangeData,
+ CheckResult,
+ CheckRun,
ChecksApiConfig,
ChecksProvider,
FetchResponse,
@@ -41,6 +43,7 @@
updateStateSetPatchset,
updateStateSetProvider,
updateStateSetResults,
+ updateStateUpdateResult,
} from './checks-model';
import {
BehaviorSubject,
@@ -112,6 +115,11 @@
if (plugin) this.reload(plugin);
}
+ updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
+ updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
+ updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
+ }
+
register(
pluginName: string,
provider: ChecksProvider,
@@ -179,15 +187,16 @@
)
.subscribe(response => {
switch (response.responseCode) {
- case ResponseCode.ERROR:
- assertIsDefined(response.errorMessage, 'errorMessage');
+ case ResponseCode.ERROR: {
+ const message = response.errorMessage ?? '-';
this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
plugin: pluginName,
- message: response.errorMessage,
+ message,
});
- updateStateSetError(pluginName, response.errorMessage, patchset);
+ updateStateSetError(pluginName, message, patchset);
break;
- case ResponseCode.NOT_LOGGED_IN:
+ }
+ case ResponseCode.NOT_LOGGED_IN: {
assertIsDefined(response.loginCallback, 'loginCallback');
this.reporting.reportExecution(Execution.CHECKS_API_NOT_LOGGED_IN, {
plugin: pluginName,
@@ -198,7 +207,8 @@
patchset
);
break;
- case ResponseCode.OK:
+ }
+ case ResponseCode.OK: {
updateStateSetResults(
pluginName,
response.runs ?? [],
@@ -207,6 +217,7 @@
patchset
);
break;
+ }
}
});
}
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 9381f30..897308e 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -17,11 +17,11 @@
import {
Action,
Category,
- CheckRun as CheckRunApi,
CheckResult as CheckResultApi,
+ CheckRun as CheckRunApi,
+ Link,
LinkIcon,
RunStatus,
- Link,
} from '../../api/checks';
import {assertNever} from '../../utils/common-util';
import {CheckResult, CheckRun} from './checks-model';
@@ -45,6 +45,8 @@
return 'bug';
case LinkIcon.CODE:
return 'code';
+ case LinkIcon.FILE_PRESENT:
+ return 'file-present';
default:
// We don't throw an assertion error here, because plugins don't have to
// be written in TypeScript, so we may encounter arbitrary strings for
@@ -70,6 +72,10 @@
return 'Link to help page';
case LinkIcon.REPORT_BUG:
return 'Link for reporting a problem';
+ case LinkIcon.CODE:
+ return 'Link to code';
+ case LinkIcon.FILE_PRESENT:
+ return 'Link to file';
default:
// We don't throw an assertion error here, because plugins don't have to
// be written in TypeScript, so we may encounter arbitrary strings for
@@ -86,8 +92,37 @@
return undefined;
}
-export function iconForCategory(category: Category) {
- switch (category) {
+export function isStatus(catStat: Category | RunStatus) {
+ return (
+ catStat === RunStatus.COMPLETED ||
+ catStat === RunStatus.RUNNABLE ||
+ catStat === RunStatus.RUNNING
+ );
+}
+
+export function labelFor(catStat: Category | RunStatus) {
+ switch (catStat) {
+ case Category.ERROR:
+ return 'error';
+ case Category.INFO:
+ return 'info';
+ case Category.WARNING:
+ return 'warning';
+ case Category.SUCCESS:
+ return 'success';
+ case RunStatus.COMPLETED:
+ return 'completed';
+ case RunStatus.RUNNABLE:
+ return 'runnable';
+ case RunStatus.RUNNING:
+ return 'running';
+ default:
+ assertNever(catStat, `Unsupported category/status: ${catStat}`);
+ }
+}
+
+export function iconFor(catStat: Category | RunStatus) {
+ switch (catStat) {
case Category.ERROR:
return 'error';
case Category.INFO:
@@ -96,8 +131,15 @@
return 'warning';
case Category.SUCCESS:
return 'check-circle-outline';
+ // Note that this is only for COMPLETED without results!
+ case RunStatus.COMPLETED:
+ return 'check-circle-outline';
+ case RunStatus.RUNNABLE:
+ return 'placeholder';
+ case RunStatus.RUNNING:
+ return 'timelapse';
default:
- assertNever(category, `Unsupported category: ${category}`);
+ assertNever(catStat, `Unsupported category/status: ${catStat}`);
}
}
@@ -145,24 +187,10 @@
export function iconForRun(run: CheckRun) {
if (run.status !== RunStatus.COMPLETED) {
- return iconForStatus(run.status);
+ return iconFor(run.status);
} else {
const category = worstCategory(run);
- return category ? iconForCategory(category) : iconForStatus(run.status);
- }
-}
-
-export function iconForStatus(status: RunStatus) {
- switch (status) {
- // Note that this is only for COMPLETED without results!
- case RunStatus.COMPLETED:
- return 'check-circle-outline';
- case RunStatus.RUNNABLE:
- return 'placeholder';
- case RunStatus.RUNNING:
- return 'timelapse';
- default:
- assertNever(status, `Unsupported status: ${status}`);
+ return category ? iconFor(category) : iconFor(run.status);
}
}
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 2d5f66e..fd276c3 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -27,6 +27,7 @@
ContextLine,
BasePatchSetNum,
RevisionPatchSetNum,
+ AccountInfo,
} from '../types/common';
import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
import {parseDate} from './date-util';
@@ -330,3 +331,18 @@
};
return diff;
}
+
+export function getCommentAuthors(threads?: CommentThread[]) {
+ if (!threads) return [];
+ const ids = new Set();
+ const authors: AccountInfo[] = [];
+ threads.forEach(t =>
+ t.comments.forEach(c => {
+ if (c.author && !ids.has(c.author._account_id)) {
+ ids.add(c.author._account_id);
+ authors.push(c.author);
+ }
+ })
+ );
+ return authors;
+}
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 4f83881..e7cc956 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -294,6 +294,11 @@
}
}
+export function modifierPressed(e: KeyboardEvent) {
+ return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+}
+
+// Deprecated. Try using "normal" KeyboardEvent and modifierPressed() above.
export function isModifierPressed(event: CustomKeyboardEvent) {
const e = getKeyboardEvent(event);
return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;