Merge "Revert "Fix bug about persisting copied votes on submit""
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
index 274fbb0..e39d091 100644
--- a/Documentation/concept-patch-sets.txt
+++ b/Documentation/concept-patch-sets.txt
@@ -88,8 +88,8 @@
evolves, such as "Added more unit tests." Unlike the change description, a patch
set description does not become a part of the project's history.
-To add a patch set description, click *Add a patch set description*, located in
-the file list, or provide it link:user-upload.html#patch_set_description[on upload].
+To add a patch set description provide it
+link:user-upload.html#patch_set_description[on upload].
GERRIT
------
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index fa2b78c..e367b07 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2712,6 +2712,23 @@
prevent callers using ETags from potentially seeing outdated submittability
information.
+`SubmitRule` interface will soon deprecated. Instead, a global `SubmitRequirement`
+can be bound by plugin.
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.inject.AbstractModule;
+
+public class MyPluginModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(SubmitRequirement.class).annotatedWith(Exports.named("myPlugin"))
+ .toInstance(myPluginSubmitRequirement);
+ }
+}
+----
+
[[change-etag-computation]]
== Change ETag Computation
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index c62350d..870e194 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1857,7 +1857,8 @@
* The given change.
* If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
- is enabled, include all open changes with the same topic.
+ is enabled OR if the `o=TOPIC_CLOSURE` query parameter is passed, include all
+ open changes with the same topic.
* For each change whose submit type is not CHERRY_PICK, include unmerged
ancestors targeting the same branch.
@@ -1884,7 +1885,7 @@
Standard link:#query-options[formatting options] can be specified
with the `o` parameter, as well as the `submitted_together` specific
-option `NON_VISIBLE_CHANGES`.
+options `NON_VISIBLE_CHANGES` and `TOPIC_CLOSURE`.
.Response
----
@@ -4581,60 +4582,6 @@
If the `path` parameter is set, the returned content is a diff of the single
file that the path refers to.
-[[submit-preview]]
-=== Submit Preview
---
-'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/preview_submit'
---
-Gets a file containing thin bundles of all modified projects if this
-change was submitted. The bundles are named `${ProjectName}.git`.
-Each thin bundle contains enough to construct the state in which a project would
-be in if this change were submitted. The base of the thin bundles are the
-current target branches, so to make use of this call in a non-racy way, first
-get the bundles and then fetch all projects contained in the bundle.
-(This assumes no non-fastforward pushes).
-
-You need to give a parameter '?format=zip' or '?format=tar' to specify the
-format for the outer container. It is always possible to use tgz, even if
-tgz is not in the list of allowed archive formats.
-
-To make good use of this call, you would roughly need code as found at:
-----
- $ curl -Lo preview_submit_test.sh http://review.example.com:8080/tools/scripts/preview_submit_test.sh
-----
-.Request
-----
- GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/preview_submit?zip HTTP/1.0
-----
-
-.Response
-----
- HTTP/1.1 200 OK
- Date: Tue, 13 Sep 2016 19:13:46 GMT
- Content-Disposition: attachment; filename="submit-preview-147.zip"
- X-Content-Type-Options: nosniff
- Cache-Control: no-cache, no-store, max-age=0, must-revalidate
- Pragma: no-cache
- Expires: Mon, 01 Jan 1990 00:00:00 GMT
- Content-Type: application/x-zip
- Transfer-Encoding: chunked
-
- [binary stuff]
-----
-
-In case of an error, the response is not a zip file but a regular json response,
-containing only the error message:
-
-.Response
-----
- HTTP/1.1 200 OK
- Content-Disposition: attachment
- Content-Type: application/json; charset=UTF-8
-
- )]}'
- "Anonymous users cannot submit"
-----
-
[[get-mergeable]]
=== Get Mergeable
--
diff --git a/java/Main.java b/java/Main.java
index 11d8234..09c8c76 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+@SuppressWarnings("DefaultPackage")
public final class Main {
private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index e31a6ce..2fb2127 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,6 +26,7 @@
import static com.google.gerrit.entities.Patch.COMMIT_MSG;
import static com.google.gerrit.entities.Patch.MERGE_LIST;
import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -41,6 +42,7 @@
import com.google.common.base.Ticker;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.jimfs.Jimfs;
@@ -80,6 +82,7 @@
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
import com.google.gerrit.extensions.api.projects.BranchApi;
import com.google.gerrit.extensions.api.projects.BranchInfo;
import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -1203,9 +1206,37 @@
}
protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
- List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+ assertSubmittedTogether(chId, ImmutableSet.of(), expected);
+ }
+
+ protected void assertSubmittedTogetherWithTopicClosure(String chId, String... expected)
+ throws Exception {
+ assertSubmittedTogether(chId, ImmutableSet.of(TOPIC_CLOSURE), expected);
+ }
+
+ protected void assertSubmittedTogether(
+ String chId,
+ ImmutableSet<SubmittedTogetherOption> submittedTogetherOptions,
+ String... expected)
+ throws Exception {
+ // This does not include NON_VISIBILE_CHANGES
+ List<ChangeInfo> actual =
+ submittedTogetherOptions.isEmpty()
+ ? gApi.changes().id(chId).submittedTogether()
+ : gApi.changes()
+ .id(chId)
+ .submittedTogether(EnumSet.copyOf(submittedTogetherOptions))
+ .changes;
+
+ EnumSet enumSetIncludingNonVisibleChanges =
+ submittedTogetherOptions.isEmpty()
+ ? EnumSet.of(NON_VISIBLE_CHANGES)
+ : EnumSet.copyOf(submittedTogetherOptions);
+ enumSetIncludingNonVisibleChanges.add(NON_VISIBLE_CHANGES);
+
+ // This includes NON_VISIBLE_CHANGES for comparison.
SubmittedTogetherInfo info =
- gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+ gApi.changes().id(chId).submittedTogether(enumSetIncludingNonVisibleChanges);
assertThat(info.nonVisibleChanges).isEqualTo(0);
assertThat(Iterables.transform(actual, i1 -> i1.changeId))
@@ -1259,12 +1290,6 @@
assertThat(replyTo.getString()).contains(email);
}
- protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
- try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
- return fetchFromBundles(result);
- }
- }
-
/**
* Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
* resulting tree id.
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 1e5598e..a1dc9e3 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.extensions.api.changes.ActionVisitor;
import com.google.gerrit.extensions.config.CapabilityDefinition;
import com.google.gerrit.extensions.config.DownloadScheme;
@@ -70,6 +71,7 @@
private final DynamicSet<PerformanceLogger> performanceLoggers;
private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
private final DynamicSet<SubmitRule> submitRules;
+ private final DynamicSet<SubmitRequirement> submitRequirements;
private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
private final DynamicSet<ChangeETagComputation> changeETagComputations;
private final DynamicSet<ActionVisitor> actionVisitors;
@@ -108,6 +110,7 @@
DynamicSet<PerformanceLogger> performanceLoggers,
DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
DynamicSet<SubmitRule> submitRules,
+ DynamicSet<SubmitRequirement> submitRequirements,
DynamicSet<ChangeMessageModifier> changeMessageModifiers,
DynamicSet<ChangeETagComputation> changeETagComputations,
DynamicSet<ActionVisitor> actionVisitors,
@@ -142,6 +145,7 @@
this.performanceLoggers = performanceLoggers;
this.projectCreationValidationListeners = projectCreationValidationListeners;
this.submitRules = submitRules;
+ this.submitRequirements = submitRequirements;
this.changeMessageModifiers = changeMessageModifiers;
this.changeETagComputations = changeETagComputations;
this.actionVisitors = actionVisitors;
@@ -216,6 +220,10 @@
return add(submitRules, submitRule);
}
+ public Registration add(SubmitRequirement submitRequirement) {
+ return add(submitRequirements, submitRequirement);
+ }
+
public Registration add(ChangeMessageModifier changeMessageModifier) {
return add(changeMessageModifiers, changeMessageModifier);
}
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index 3b0ba3b..debd9d8 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -168,6 +168,7 @@
MoreFiles.deleteRecursively(userhome.toPath(), ALLOW_INSECURE);
} catch (IOException e) {
e.printStackTrace();
+ throw new RuntimeException("Failed to cleanup userhome", e);
}
});
}
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index 37f6c2c..09a8993 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -32,16 +32,27 @@
public final class IoUtil {
public static void copyWithThread(InputStream src, OutputStream dst) {
new Thread("IoUtil-Copy") {
+ // We cannot propagate the exception since this code is running in a background thread.
+ // Printing the stacktrace is the best we can do. Hence ignoring the exception after printing
+ // the stacktrace is OK and it's fine to suppress the warning for the CatchAndPrintStackTrace
+ // bug pattern here.
+ @SuppressWarnings("CatchAndPrintStackTrace")
@Override
public void run() {
try {
+ copyIo();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void copyIo() throws IOException {
+ try {
final byte[] buf = new byte[256];
int n;
while (0 < (n = src.read(buf))) {
dst.write(buf, 0, n);
}
- } catch (IOException e) {
- e.printStackTrace();
} finally {
try {
src.close();
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 13e0b53..3f91cc7 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -15,11 +15,23 @@
package com.google.gerrit.entities;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import java.util.Optional;
-/** Entity describing a requirement that should be met for a change to become submittable. */
+/**
+ * Entity describing a requirement that should be met for a change to become submittable.
+ *
+ * <p>There are two ways to contribute {@link SubmitRequirement}:
+ *
+ * <ul>
+ * <li>Set per-project in project.config (see {@link
+ * com.google.gerrit.server.project.ProjectState#getSubmitRequirements()}
+ * <li>Bind a global {@link SubmitRequirement} that will be evaluated for all projects.
+ * </ul>
+ */
+@ExtensionPoint
@AutoValue
public abstract class SubmitRequirement {
/** Requirement name. */
@@ -56,7 +68,12 @@
/**
* Boolean value indicating if the {@link SubmitRequirement} definition can be overridden in child
- * projects. Default is false.
+ * projects.
+ *
+ * <p>For globally bound {@link SubmitRequirement}, indicates if can be overridden by {@link
+ * SubmitRequirement} in project.config.
+ *
+ * <p>Default is false.
*/
public abstract boolean allowOverrideInChildProjects();
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 1307516..b659cca 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -51,12 +51,6 @@
ChangeInfo submit(SubmitInput in) throws RestApiException;
- default BinaryResult submitPreview() throws RestApiException {
- return submitPreview("zip");
- }
-
- BinaryResult submitPreview(String format) throws RestApiException;
-
ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
@@ -369,11 +363,6 @@
}
@Override
- public BinaryResult submitPreview(String format) throws RestApiException {
- throw new NotImplementedException();
- }
-
- @Override
public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
throw new NotImplementedException();
}
diff --git a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index e2cab4d..68a4e88 100644
--- a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,5 +16,6 @@
/** Output options available for submitted_together requests. */
public enum SubmittedTogetherOption {
- NON_VISIBLE_CHANGES;
+ NON_VISIBLE_CHANGES,
+ TOPIC_CLOSURE;
}
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 634992e..787508ab 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -110,6 +110,11 @@
return 1;
}
+ // This is a value class that allows adding attributes by subclassing.
+ // Doing this is discouraged and using composition rather than inheritance to add fields to value
+ // types is preferred. However this class is part of the extension API, hence we cannot change it
+ // without breaking the API. Hence suppress the EqualsGetClass warning here.
+ @SuppressWarnings("EqualsGetClass")
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index bf72e83..a4e0baa 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -88,6 +88,11 @@
}
@Override
+ public String toString() {
+ return super.toString() + ", value=" + this.value;
+ }
+
+ @Override
public int hashCode() {
return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
}
diff --git a/java/com/google/gerrit/extensions/common/BlameInfo.java b/java/com/google/gerrit/extensions/common/BlameInfo.java
index df3f373..6ee677e 100644
--- a/java/com/google/gerrit/extensions/common/BlameInfo.java
+++ b/java/com/google/gerrit/extensions/common/BlameInfo.java
@@ -28,7 +28,7 @@
if (this == o) {
return true;
}
- if (o == null || getClass() != o.getClass()) {
+ if (o == null || !(o instanceof BlameInfo)) {
return false;
}
BlameInfo blameInfo = (BlameInfo) o;
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 445a73a..ce22ae8 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -97,7 +97,8 @@
IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
break;
case DIFF:
- data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
+ data.put(
+ "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
data.put(
"changeRequestsPath",
IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 0e54b0a..8395d12 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -104,12 +104,6 @@
ListChangesOption.SKIP_DIFFSTAT,
ListChangesOption.SUBMIT_REQUIREMENTS);
- public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
- ImmutableSet.of(
- ListChangesOption.ALL_COMMITS,
- ListChangesOption.ALL_REVISIONS,
- ListChangesOption.SKIP_DIFFSTAT);
-
public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
if (requestedURL == null) {
return null;
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index ae13fb3..fd8ca96 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -105,6 +105,8 @@
return getChild(0).hashCode() * 31 + getChild(1).hashCode();
}
+ // Suppress the EqualsGetClass warning as this is legacy code.
+ @SuppressWarnings("EqualsGetClass")
@Override
public boolean equals(Object other) {
if (other == null) {
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index d9e33ea..ffd442b 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -112,6 +112,8 @@
return pred.hashCode();
}
+ // Suppress the EqualsGetClass warning as this is legacy code.
+ @SuppressWarnings("EqualsGetClass")
@Override
public boolean equals(Object other) {
if (other == null || getClass() != other.getClass()) {
diff --git a/java/com/google/gerrit/index/query/IntPredicate.java b/java/com/google/gerrit/index/query/IntPredicate.java
index 16e59e7..a98e0b1 100644
--- a/java/com/google/gerrit/index/query/IntPredicate.java
+++ b/java/com/google/gerrit/index/query/IntPredicate.java
@@ -37,6 +37,8 @@
return getOperator().hashCode() * 31 + intValue;
}
+ // Suppress the EqualsGetClass warning as this is legacy code.
+ @SuppressWarnings("EqualsGetClass")
@Override
public boolean equals(Object other) {
if (other == null) {
diff --git a/java/com/google/gerrit/index/query/NotPredicate.java b/java/com/google/gerrit/index/query/NotPredicate.java
index 14cb740..75e37b7 100644
--- a/java/com/google/gerrit/index/query/NotPredicate.java
+++ b/java/com/google/gerrit/index/query/NotPredicate.java
@@ -21,7 +21,7 @@
import java.util.List;
/** Negates the result of another predicate. */
-public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
+public final class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
private final Predicate<T> that;
protected NotPredicate(Predicate<T> that) {
@@ -87,7 +87,7 @@
if (other == null) {
return false;
}
- return getClass() == other.getClass()
+ return other instanceof NotPredicate
&& getChildren().equals(((Predicate<?>) other).getChildren());
}
diff --git a/java/com/google/gerrit/index/query/OperatorPredicate.java b/java/com/google/gerrit/index/query/OperatorPredicate.java
index 368ee24..ea7717f 100644
--- a/java/com/google/gerrit/index/query/OperatorPredicate.java
+++ b/java/com/google/gerrit/index/query/OperatorPredicate.java
@@ -47,6 +47,8 @@
return getOperator().hashCode() * 31 + getValue().hashCode();
}
+ // Suppress the EqualsGetClass warning as this is legacy code.
+ @SuppressWarnings("EqualsGetClass")
@Override
public boolean equals(Object other) {
if (other == null) {
diff --git a/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
index 9bc3769..1c31af3 100644
--- a/java/com/google/gerrit/index/query/OrPredicate.java
+++ b/java/com/google/gerrit/index/query/OrPredicate.java
@@ -105,6 +105,8 @@
return getChild(0).hashCode() * 31 + getChild(1).hashCode();
}
+ // Suppress the EqualsGetClass warning as this is legacy code.
+ @SuppressWarnings("EqualsGetClass")
@Override
public boolean equals(Object other) {
if (other == null) {
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 2809a14..ce433d7 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -20,6 +20,7 @@
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
import com.google.gerrit.extensions.common.AccountVisibility;
import com.google.gerrit.extensions.config.FactoryModule;
@@ -204,6 +205,9 @@
modules.add(new DefaultSubmitRuleModule());
modules.add(new IgnoreSelfApprovalRuleModule());
+ // Global submit requirements
+ DynamicSet.setOf(binder(), SubmitRequirement.class);
+
bind(ChangeJson.Factory.class).toProvider(Providers.of(null));
bind(EventUtil.class).toProvider(Providers.of(null));
bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
diff --git a/java/com/google/gerrit/server/AuditEvent.java b/java/com/google/gerrit/server/AuditEvent.java
index 773a307..54bbe23 100644
--- a/java/com/google/gerrit/server/AuditEvent.java
+++ b/java/com/google/gerrit/server/AuditEvent.java
@@ -82,6 +82,12 @@
return uuid.hashCode();
}
+ // This is a value class that allows adding attributes by subclassing.
+ // Doing this is discouraged and using composition rather than inheritance to add fields to value
+ // types is preferred. However this class is part of the plugin API (used in the AuditListener
+ // extension point), hence we cannot change it without breaking plugins. Hence suppress the
+ // EqualsGetClass warning here.
+ @SuppressWarnings("EqualsGetClass")
@Override
public boolean equals(Object obj) {
if (this == obj) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7080417..9470931 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -144,6 +144,7 @@
visibility = ["//visibility:public"],
deps = [
":server",
+ "//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/server/git/receive",
"//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 3f642f7..51c5ecd 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -64,7 +64,7 @@
/** Returns true if the account is backed by the realm, false otherwise. */
default boolean accountBelongsToRealm(
- @SuppressWarnings("unused") Collection<ExternalId> externalIds) {
+ @SuppressWarnings("unused") Collection<ExternalId> externalIds) throws IOException {
return false;
}
}
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 764c46d..9aa9306 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -86,7 +86,6 @@
import com.google.gerrit.server.restapi.change.ListRobotComments;
import com.google.gerrit.server.restapi.change.Mergeable;
import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.restapi.change.PreviewSubmit;
import com.google.gerrit.server.restapi.change.PutDescription;
import com.google.gerrit.server.restapi.change.Rebase;
import com.google.gerrit.server.restapi.change.Reviewed;
@@ -119,7 +118,6 @@
private final Rebase rebase;
private final RebaseUtil rebaseUtil;
private final Submit submit;
- private final PreviewSubmit submitPreview;
private final Reviewed.PutReviewed putReviewed;
private final Reviewed.DeleteReviewed deleteReviewed;
private final RevisionResource revision;
@@ -167,7 +165,6 @@
Rebase rebase,
RebaseUtil rebaseUtil,
Submit submit,
- PreviewSubmit submitPreview,
Reviewed.PutReviewed putReviewed,
Reviewed.DeleteReviewed deleteReviewed,
Files files,
@@ -213,7 +210,6 @@
this.rebaseUtil = rebaseUtil;
this.review = review;
this.submit = submit;
- this.submitPreview = submitPreview;
this.files = files;
this.putReviewed = putReviewed;
this.deleteReviewed = deleteReviewed;
@@ -270,16 +266,6 @@
}
@Override
- public BinaryResult submitPreview(String format) throws RestApiException {
- try {
- submitPreview.setFormat(format);
- return submitPreview.apply(revision).value();
- } catch (Exception e) {
- throw asRestApiException("Cannot get submit preview", e);
- }
- }
-
- @Override
public ChangeApi rebase(RebaseInput in) throws RestApiException {
try {
return changes.id(rebaseAsInfo(in)._number);
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index e57238b..4114f64 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -790,7 +790,7 @@
}
private boolean submittable(ChangeData cd) {
- return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
+ return cd.submitRequirements().values().stream().allMatch(srResult -> srResult.fulfilled());
}
private void setSubmitter(ChangeData cd, ChangeInfo out) {
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 7d0bda1..2b0d512 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -95,7 +95,7 @@
public abstract static class Result {
private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
return new AutoValue_ConsistencyChecker_Result(
- notes.getChangeId(), notes.getChange(), problems);
+ notes.getChangeId(), notes.getChange(), ImmutableList.copyOf(problems));
}
public abstract Change.Id id();
@@ -103,7 +103,7 @@
@Nullable
public abstract Change change();
- public abstract List<ProblemInfo> problems();
+ public abstract ImmutableList<ProblemInfo> problems();
}
private final ChangeNotes.Factory notesFactory;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 7878b34..7d3ff12 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -18,6 +18,7 @@
import com.google.common.base.Ticker;
import com.google.common.cache.Cache;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.api.changes.ActionVisitor;
import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -434,6 +435,7 @@
DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
DynamicSet.setOf(binder(), SubmitRule.class);
+ DynamicSet.setOf(binder(), SubmitRequirement.class);
DynamicSet.setOf(binder(), QuotaEnforcer.class);
DynamicSet.setOf(binder(), PerformanceLogger.class);
DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
index c54ab25..4d2805d 100644
--- a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -19,6 +19,8 @@
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
@@ -37,12 +39,13 @@
@VisibleForTesting
@AutoValue
public abstract static class Result {
- public abstract Map<String, Ref> allRefs();
+ public abstract ImmutableMap<String, Ref> allRefs();
- public abstract Set<ObjectId> additionalHaves();
+ public abstract ImmutableSet<ObjectId> additionalHaves();
public static Result create(Map<String, Ref> allRefs, Set<ObjectId> additionalHaves) {
- return new AutoValue_TestRefAdvertiser_Result(allRefs, additionalHaves);
+ return new AutoValue_TestRefAdvertiser_Result(
+ ImmutableMap.copyOf(allRefs), ImmutableSet.copyOf(additionalHaves));
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index c554ca5..b8b8a2c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -61,7 +61,7 @@
.weigher(Weigher.class)
.maximumWeight(10 << 20)
.diskLimit(-1)
- .version(2)
+ .version(3)
.keySerializer(Key.Serializer.INSTANCE)
.valueSerializer(ChangeNotesState.Serializer.INSTANCE);
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 5cf3a64..a3a2b4b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -40,11 +40,13 @@
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
import static java.util.Comparator.comparing;
+import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.joining;
import com.google.common.base.Enums;
import com.google.common.base.Splitter;
import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.ListMultimap;
@@ -70,6 +72,7 @@
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Label.Status;
import com.google.gerrit.entities.SubmitRequirementResult;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.AssigneeStatusUpdate;
@@ -83,6 +86,7 @@
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -95,6 +99,7 @@
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
+import java.util.stream.Collectors;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.InvalidObjectIdException;
import org.eclipse.jgit.lib.ObjectId;
@@ -312,10 +317,81 @@
}
result.put(a.key().patchSetId(), a.build());
}
+ if (status != null && status.isClosed() && !isAnyApprovalCopied(result)) {
+ // If the change is closed, check if there are "submit records" with approvals that do not
+ // exist on the latest patch-set and copy them to the latest patch-set.
+ // We do not invoke this logic if any approval is copied. This is because prior to change
+ // https://gerrit-review.googlesource.com/c/gerrit/+/318135 we used to copy approvals
+ // dynamically (e.g. when requesting the change page). After that change, we started
+ // persisting copied votes in NoteDb, so we don't need to do this back-filling.
+ // Prior to that change (318135), we could've had changes with dynamically copied approvals
+ // that were merged in NoteDb but these approvals do not exist on the latest patch-set, so
+ // we need to back-fill these approvals.
+ PatchSet.Id latestPs = buildCurrentPatchSetId();
+ backFillMissingCopiedApprovalsFromSubmitRecords(result, latestPs).stream()
+ .forEach(a -> result.put(latestPs, a));
+ }
result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
return result;
}
+ /**
+ * Returns patch-set approvals that do not exist on the latest patch-set but for which a submit
+ * record exists in NoteDb when the change was merged.
+ */
+ private List<PatchSetApproval> backFillMissingCopiedApprovalsFromSubmitRecords(
+ ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals, @Nullable PatchSet.Id latestPs) {
+ List<PatchSetApproval> copiedApprovals = new ArrayList<>();
+ if (latestPs == null) {
+ return copiedApprovals;
+ }
+ List<PatchSetApproval> approvalsOnLatestPs = allApprovals.get(latestPs);
+ ListMultimap<Account.Id, PatchSetApproval> approvalsByUser = getApprovalsByUser(allApprovals);
+ List<SubmitRecord.Label> submitRecordLabels =
+ submitRecords.stream()
+ .filter(r -> r.labels != null)
+ .flatMap(r -> r.labels.stream())
+ .filter(label -> Status.OK.equals(label.status) || Status.MAY.equals(label.status))
+ .collect(Collectors.toList());
+ for (SubmitRecord.Label recordLabel : submitRecordLabels) {
+ String labelName = recordLabel.label;
+ Account.Id appliedBy = recordLabel.appliedBy;
+ if (appliedBy == null || labelName == null) {
+ continue;
+ }
+ boolean existsAtLatestPs =
+ approvalsOnLatestPs.stream()
+ .anyMatch(a -> a.accountId().equals(appliedBy) && a.label().equals(labelName));
+ if (existsAtLatestPs) {
+ continue;
+ }
+ // Search for an approval for this label on the max previous patch-set and copy the approval.
+ Collection<PatchSetApproval> userApprovals =
+ approvalsByUser.get(appliedBy).stream()
+ .filter(approval -> approval.label().equals(labelName))
+ .collect(Collectors.toList());
+ if (userApprovals.isEmpty()) {
+ continue;
+ }
+ PatchSetApproval lastApproved =
+ Collections.max(userApprovals, comparingInt(a -> a.patchSetId().get()));
+ copiedApprovals.add(lastApproved.copyWithPatchSet(latestPs));
+ }
+ return copiedApprovals;
+ }
+
+ private boolean isAnyApprovalCopied(ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+ return allApprovals.values().stream().anyMatch(approval -> approval.copied());
+ }
+
+ private ListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
+ ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+ return allApprovals.values().stream()
+ .collect(
+ ImmutableListMultimap.toImmutableListMultimap(
+ PatchSetApproval::accountId, Function.identity()));
+ }
+
private List<ReviewerStatusUpdate> buildReviewerUpdates() {
List<ReviewerStatusUpdate> result = new ArrayList<>();
HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 10aa9cd..c8a6f60 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -149,7 +149,7 @@
// we have to investigate separately (deferred tags) then perform a reachability check starting
// from all visible branches (refs/heads/*).
Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts);
- List<Ref> visibleRefs = initialRefFilter.visibleRefs();
+ List<Ref> visibleRefs = new ArrayList<>(initialRefFilter.visibleRefs());
if (!initialRefFilter.deferredTags().isEmpty()) {
try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts);
@@ -198,13 +198,15 @@
skipFilterCount.increment();
logger.atFinest().log(
"Fast path, all refs are visible because user has READ on refs/*: %s", refs);
- return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+ return new AutoValue_DefaultRefFilter_Result(
+ ImmutableList.copyOf(refs), ImmutableList.of());
} else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
skipFilterCount.increment();
refs = fastHideRefsMetaConfig(refs);
logger.atFinest().log(
"Fast path, all refs except %s are visible: %s", RefNames.REFS_CONFIG, refs);
- return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+ return new AutoValue_DefaultRefFilter_Result(
+ ImmutableList.copyOf(refs), ImmutableList.of());
}
}
logger.atFinest().log("Doing full ref filtering");
@@ -263,7 +265,9 @@
resultRefs.add(ref);
}
}
- Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
+ Result result =
+ new AutoValue_DefaultRefFilter_Result(
+ ImmutableList.copyOf(resultRefs), ImmutableList.copyOf(deferredTags));
logger.atFinest().log("Result of ref filtering = %s", result);
return result;
}
@@ -400,12 +404,12 @@
@AutoValue
abstract static class Result {
/** Subset of the refs passed into the computation that is visible to the user. */
- abstract List<Ref> visibleRefs();
+ abstract ImmutableList<Ref> visibleRefs();
/**
* List of tags where we couldn't figure out visibility in the first pass and need to do an
* expensive ref walk.
*/
- abstract List<Ref> deferredTags();
+ abstract ImmutableList<Ref> deferredTags();
}
}
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index e64f8b6..621f1d0 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -125,7 +125,7 @@
abstract static class EntryKey {
public abstract String ref();
- public abstract List<String> patterns();
+ public abstract ImmutableList<String> patterns();
static EntryKey create(String refName, List<AccessSection> sections) {
List<String> patterns = new ArrayList<>(sections.size());
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index dbe96cb..83c4bab7 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -15,7 +15,6 @@
package com.google.gerrit.server.project;
import com.google.common.collect.ImmutableList;
-import com.google.common.collect.MoreCollectors;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.SubmitRecord;
@@ -48,11 +47,11 @@
* com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
*/
public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
- SubmitRuleEvaluator.Factory evaluator, ChangeData cd) {
+ ChangeData cd) {
// We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
// This doesn't have an effect since we never call this class (i.e. to evaluate submit
// requirements) for closed changes.
- List<SubmitRecord> records = evaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
+ List<SubmitRecord> records = cd.submitRecords(SubmitRuleOptions.defaults());
List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
ObjectId commitId = cd.currentPatchSet().commitId();
return records.stream()
@@ -95,7 +94,13 @@
List<Label> labels, List<LabelType> labelTypes, ObjectId psCommitId) {
ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
for (Label label : labels) {
- LabelType labelType = getLabelType(labelTypes, label.label);
+ Optional<LabelType> maybeLabelType = getLabelType(labelTypes, label.label);
+ if (!maybeLabelType.isPresent()) {
+ // Label type might have been removed from the project config. We don't have information
+ // if it was blocking or not, hence we skip the label.
+ continue;
+ }
+ LabelType labelType = maybeLabelType.get();
if (!isBlocking(labelType)) {
continue;
}
@@ -230,9 +235,19 @@
status == Status.FAIL ? atoms : ImmutableList.of());
}
- private static LabelType getLabelType(List<LabelType> labelTypes, String labelName) {
- return labelTypes.stream()
- .filter(lt -> lt.getName().equals(labelName))
- .collect(MoreCollectors.onlyElement());
+ private static Optional<LabelType> getLabelType(List<LabelType> labelTypes, String labelName) {
+ List<LabelType> label =
+ labelTypes.stream()
+ .filter(lt -> lt.getName().equals(labelName))
+ .collect(Collectors.toList());
+ if (label.isEmpty()) {
+ // Label might have been removed from the project.
+ logger.atFine().log("Label '%s' was not found for the project.", labelName);
+ return Optional.empty();
+ } else if (label.size() > 1) {
+ logger.atWarning().log("Found more than one label definition for label name '%s'", labelName);
+ return Optional.empty();
+ }
+ return Optional.of(label.get(0));
}
}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index cc2c805..4d081d7 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.project;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import com.google.common.collect.ImmutableMap;
@@ -24,6 +25,7 @@
import com.google.gerrit.entities.SubmitRequirementResult;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
import com.google.inject.AbstractModule;
@@ -34,13 +36,15 @@
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Stream;
/** Evaluates submit requirements for different change data. */
public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
private final ProjectCache projectCache;
- private final SubmitRuleEvaluator.Factory legacyEvaluator;
+ private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
public static Module module() {
return new AbstractModule() {
@@ -57,10 +61,10 @@
private SubmitRequirementsEvaluatorImpl(
Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
ProjectCache projectCache,
- SubmitRuleEvaluator.Factory legacyEvaluator) {
+ PluginSetContext<SubmitRequirement> globalSubmitRequirements) {
this.queryBuilder = queryBuilder;
this.projectCache = projectCache;
- this.legacyEvaluator = legacyEvaluator;
+ this.globalSubmitRequirements = globalSubmitRequirements;
}
@Override
@@ -76,7 +80,7 @@
Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
if (includeLegacy) {
Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
- SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
+ SubmitRequirementsAdapter.getLegacyRequirements(cd);
result =
SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
projectConfigRequirements, legacyReqs);
@@ -121,15 +125,52 @@
}
}
- /** Evaluate and return submit requirements stored in this project's config and its parents. */
+ /**
+ * Evaluate and return all {@link SubmitRequirement}s.
+ *
+ * <p>This includes all globally bound {@link SubmitRequirement}s, as well as requirements stored
+ * in this project's config and its parents.
+ *
+ * <p>The behaviour in case of the name match is controlled by {@link
+ * SubmitRequirement#allowOverrideInChildProjects} of global {@link SubmitRequirement}.
+ */
private Map<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+ Map<String, SubmitRequirement> globalRequirements = getGlobalRequirements();
+
ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
- Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
- Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
+ Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+
+ ImmutableMap<String, SubmitRequirement> requirements =
+ Stream.concat(
+ globalRequirements.entrySet().stream(),
+ projectConfigRequirements.entrySet().stream())
+ .collect(
+ toImmutableMap(
+ Map.Entry::getKey,
+ Map.Entry::getValue,
+ (globalSubmitRequirement, projectConfigRequirement) ->
+ // Override with projectConfigRequirement if allowed by
+ // globalSubmitRequirement configuration
+ globalSubmitRequirement.allowOverrideInChildProjects()
+ ? projectConfigRequirement
+ : globalSubmitRequirement));
+ Map<SubmitRequirement, SubmitRequirementResult> results = new HashMap<>();
for (SubmitRequirement requirement : requirements.values()) {
- result.put(requirement, evaluateRequirement(requirement, cd));
+ results.put(requirement, evaluateRequirement(requirement, cd));
}
- return result;
+ return results;
+ }
+
+ /**
+ * Returns a map of all global {@link SubmitRequirement}s, keyed by their lower-case name.
+ *
+ * <p>The global {@link SubmitRequirement}s apply to all projects and can be bound by plugins.
+ */
+ private Map<String, SubmitRequirement> getGlobalRequirements() {
+ return globalSubmitRequirements.stream()
+ .collect(
+ toImmutableMap(
+ globalRequirement -> globalRequirement.name().toLowerCase(), Function.identity()));
}
/** Evaluate the predicate recursively using change data. */
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index e38ff3d..f3a8a9d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -424,6 +424,10 @@
return this;
}
+ public StorageConstraint getStorageConstraint() {
+ return storageConstraint;
+ }
+
/** Returns {@code true} if we allow reading data from NoteDb. */
public boolean lazyload() {
return storageConstraint.ordinal()
@@ -948,10 +952,6 @@
* com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
*/
public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
- if (!experimentFeatures.isFeatureEnabled(
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)) {
- return Collections.emptyMap();
- }
if (submitRequirements == null) {
if (!lazyload()) {
return Collections.emptyMap();
@@ -969,7 +969,7 @@
.filter(r -> !r.isLegacy())
.collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
- SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
+ SubmitRequirementsAdapter.getLegacyRequirements(this);
submitRequirements =
SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
projectConfigRequirements, legacyRequirements);
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index b2bc6aa..a65d0a0 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -29,6 +29,7 @@
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
import java.util.Optional;
public class EqualsLabelPredicate extends ChangeIndexPredicate {
@@ -100,6 +101,7 @@
boolean hasVote = false;
int matchingVotes = 0;
+ StorageConstraint currentStorageConstraint = object.getStorageConstraint();
object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
for (PatchSetApproval p : object.currentApprovals()) {
if (labelType.matches(p)) {
@@ -109,7 +111,7 @@
}
}
}
-
+ object.setStorageConstraint(currentStorageConstraint);
if (!hasVote && expVal == 0) {
return true;
}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 02b4c13..e09f2f4 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -143,7 +143,6 @@
get(REVISION_KIND, "related").to(GetRelated.class);
get(REVISION_KIND, "review").to(GetReview.class);
post(REVISION_KIND, "review").to(PostReview.class);
- get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
post(REVISION_KIND, "submit").to(Submit.class);
post(REVISION_KIND, "rebase").to(Rebase.class);
put(REVISION_KIND, "description").to(PutDescription.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
deleted file mode 100644
index 4acf809..0000000
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2016 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.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ArchiveFormatInternal;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream.LimitExceededException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.submit.MergeOp;
-import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Collection;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.storage.pack.PackConfig;
-import org.eclipse.jgit.transport.BundleWriter;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-
-public class PreviewSubmit implements RestReadView<RevisionResource> {
- private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
-
- private final Provider<MergeOp> mergeOpProvider;
- private final AllowedFormats allowedFormats;
- private int maxBundleSize;
- private String format;
-
- @Option(name = "--format")
- public void setFormat(String f) {
- this.format = f;
- }
-
- @Inject
- PreviewSubmit(
- Provider<MergeOp> mergeOpProvider,
- AllowedFormats allowedFormats,
- @GerritServerConfig Config cfg) {
- this.mergeOpProvider = mergeOpProvider;
- this.allowedFormats = allowedFormats;
- this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
- }
-
- @Override
- public Response<BinaryResult> apply(RevisionResource rsrc)
- throws RestApiException, UpdateException, IOException, ConfigInvalidException,
- PermissionBackendException {
- if (Strings.isNullOrEmpty(format)) {
- throw new BadRequestException("format is not specified");
- }
- ArchiveFormatInternal f = allowedFormats.extensions.get("." + format);
- if (f == null && format.equals("tgz")) {
- // Always allow tgz, even when the allowedFormats doesn't contain it.
- // Then we allow at least one format even if the list of allowed
- // formats is empty.
- f = ArchiveFormatInternal.TGZ;
- }
- if (f == null) {
- throw new BadRequestException("unknown archive format");
- }
-
- Change change = rsrc.getChange();
- if (!change.isNew()) {
- throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
- }
- if (!rsrc.getUser().isIdentifiedUser()) {
- throw new MethodNotAllowedException("Anonymous users cannot submit");
- }
-
- return Response.ok(getBundles(rsrc, f));
- }
-
- private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormatInternal f)
- throws RestApiException, UpdateException, IOException, ConfigInvalidException,
- PermissionBackendException {
- IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
- Change change = rsrc.getChange();
-
- @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
- MergeOp op = mergeOpProvider.get();
- try {
- op.merge(change, caller, false, new SubmitInput(), true);
- BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
- bin.disableGzip()
- .setContentType(f.getMimeType())
- .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
- return bin;
- } catch (RestApiException
- | UpdateException
- | IOException
- | ConfigInvalidException
- | RuntimeException
- | PermissionBackendException e) {
- op.close();
- throw e;
- }
- }
-
- private static class SubmitPreviewResult extends BinaryResult {
-
- private final MergeOp mergeOp;
- private final ArchiveFormatInternal archiveFormat;
- private final int maxBundleSize;
-
- private SubmitPreviewResult(
- MergeOp mergeOp, ArchiveFormatInternal archiveFormat, int maxBundleSize) {
- this.mergeOp = mergeOp;
- this.archiveFormat = archiveFormat;
- this.maxBundleSize = maxBundleSize;
- }
-
- @Override
- public void writeTo(OutputStream out) throws IOException {
- try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
- MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
- for (Project.NameKey p : mergeOp.getAllProjects()) {
- OpenRepo or = orm.getRepo(p);
- BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
- bw.setObjectCountCallback(null);
- bw.setPackConfig(new PackConfig(or.getRepo()));
- Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
- for (ReceiveCommand r : refs) {
- bw.include(r.getRefName(), r.getNewId());
- ObjectId oldId = r.getOldId();
- if (!oldId.equals(ObjectId.zeroId())
- // Probably the client doesn't already have NoteDb data.
- && !RefNames.isNoteDbMetaRef(r.getRefName())) {
- bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
- }
- }
- LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
- bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
- // This naming scheme cannot produce directory/file conflicts
- // as no projects contains ".git/":
- String path = p.get() + ".git";
- archiveFormat.putEntry(aos, path, bos.toByteArray());
- }
- } catch (LimitExceededException e) {
- throw new NotImplementedException("The bundle is too big to generate at the server", e);
- } catch (NoSuchProjectException e) {
- throw new IOException(e);
- }
- }
-
- @Override
- public void close() throws IOException {
- mergeOp.close();
- }
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 5155a0d..154e45a 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -304,7 +304,10 @@
return null; // submit not visible
}
- ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+ ChangeSet cs =
+ mergeSuperSet
+ .get()
+ .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
String topic = change.getTopic();
int topicSize = 0;
if (!Strings.isNullOrEmpty(topic)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 214a001..c18e7c2 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
import static java.util.Collections.reverseOrder;
import static java.util.stream.Collectors.toList;
@@ -127,7 +128,10 @@
int hidden;
if (c.isNew()) {
- ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
+ ChangeSet cs =
+ mergeSuperSet
+ .get()
+ .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
cds = ensureRequiredDataIsLoaded(cs.changes().asList());
hidden = cs.nonVisibleChanges().size();
} else if (c.isMerged()) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 64b60bb..b431299 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -480,7 +480,9 @@
logger.atFine().log("Beginning integration of %s", change);
try {
ChangeSet indexBackedChangeSet =
- mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
+ mergeSuperSet
+ .setMergeOpRepoManager(orm)
+ .completeChangeSet(change, caller, /* includingTopicClosure= */ false);
if (!indexBackedChangeSet.ids().contains(change.getId())) {
// indexBackedChangeSet contains only open changes, if the change is missing in this set
// it might be that the change was concurrently submitted in the meantime.
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 67f2907..8581e20 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -92,7 +92,19 @@
return this;
}
- public ChangeSet completeChangeSet(Change change, CurrentUser user)
+ /**
+ * Gets the ChangeSet of this {@code change} based on visiblity of the {@code user}. if
+ * change.submitWholeTopic is true, we return the topic closure as well as the dependent changes
+ * of the topic closure. Otherwise, we return just the dependent changes.
+ *
+ * @param change the change for which we get the dependent changes / topic closure.
+ * @param user the current user for visibility purposes.
+ * @param includingTopicClosure when true, return as if change.submitWholeTopic = true, so we
+ * return the topic closure.
+ * @return {@link ChangeSet} object that represents the dependent changes and/or topic closure of
+ * the requested change.
+ */
+ public ChangeSet completeChangeSet(Change change, CurrentUser user, boolean includingTopicClosure)
throws IOException, PermissionBackendException {
try {
if (orm == null) {
@@ -113,7 +125,7 @@
}
ChangeSet changeSet = new ChangeSet(cd, visible);
- if (wholeTopicEnabled(cfg)) {
+ if (wholeTopicEnabled(cfg) || includingTopicClosure) {
return completeChangeSetIncludingTopics(changeSet, user);
}
try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 70d39b3..dc8f713 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.api.change;
import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.common.truth.Truth8.assertThat;
@@ -31,6 +32,7 @@
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.client.ChangeStatus.MERGED;
import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -2838,6 +2840,53 @@
}
@Test
+ public void labelPermissionsChange_doesNotAffectCurrentVotes() throws Exception {
+ String heads = "refs/heads/*";
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ // Approve the change as user
+ requestScopeOperations.setApiUser(user.id());
+ approve(changeId);
+ assertThat(
+ gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+ .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+ .isEqualTo(ImmutableMap.of(user.id(), 2));
+
+ // Remove permissions for CODE_REVIEW. The user still has [-1,+1], inherited from All-Projects.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .remove(labelPermissionKey(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS))
+ .update();
+
+ // No permissions to vote +2
+ assertThrows(AuthException.class, () -> approve(changeId));
+
+ assertThat(
+ get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+ .map(vote -> vote.value))
+ .containsExactly(2);
+
+ // The change is still submittable
+ requestScopeOperations.setApiUser(admin.id());
+ gApi.changes().id(changeId).current().submit();
+ assertThat(info(changeId).status).isEqualTo(MERGED);
+
+ // The +2 vote out of permissions range is still present.
+ assertThat(
+ get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+ .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+ .isEqualTo(ImmutableMap.of(user.id(), 2, admin.id(), 0));
+ }
+
+ @Test
public void createEmptyChange() throws Exception {
ChangeInput in = new ChangeInput();
in.branch = Constants.MASTER;
@@ -3089,7 +3138,7 @@
gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
gApi.changes().id(r.getChangeId()).current().submit();
- assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+ assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
}
@Test
@@ -3115,7 +3164,7 @@
.update();
requestScopeOperations.setApiUser(user.id());
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
- assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+ assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
}
@Test
@@ -3554,7 +3603,7 @@
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
- assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+ assertThat(change.status).isEqualTo(MERGED);
assertThat(change.submissionId).isNotNull();
assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
@@ -3717,7 +3766,7 @@
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
- assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+ assertThat(change.status).isEqualTo(MERGED);
assertThat(change.submissionId).isNotNull();
assertThat(change.labels.keySet())
.containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
@@ -3734,7 +3783,7 @@
result.assertOkStatus();
ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
- assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+ assertThat(change.status).isEqualTo(MERGED);
assertThat(change.submissionId).isNotNull();
assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
@@ -3751,11 +3800,11 @@
result.assertOkStatus();
ChangeInfo firstChange = gApi.changes().id(first.getChangeId()).get();
- assertThat(firstChange.status).isEqualTo(ChangeStatus.MERGED);
+ assertThat(firstChange.status).isEqualTo(MERGED);
assertThat(firstChange.submissionId).isNotNull();
ChangeInfo secondChange = gApi.changes().id(second.getChangeId()).get();
- assertThat(secondChange.status).isEqualTo(ChangeStatus.MERGED);
+ assertThat(secondChange.status).isEqualTo(MERGED);
assertThat(secondChange.submissionId).isNotNull();
assertThat(secondChange.submissionId).isEqualTo(firstChange.submissionId);
@@ -4166,9 +4215,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withLabelEqualsMax() throws Exception {
configSubmitRequirement(
project,
@@ -4195,9 +4241,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
projectOperations
@@ -4239,9 +4282,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
configSubmitRequirement(
project,
@@ -4283,9 +4323,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
projectOperations
@@ -4335,9 +4372,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withLabelEqualsAny() throws Exception {
configSubmitRequirement(
project,
@@ -4367,9 +4401,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
throws Exception {
configSubmitRequirement(
@@ -4408,9 +4439,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
throws Exception {
configSubmitRequirement(
@@ -4434,9 +4462,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
configLabel("build-cop-override", LabelFunction.NO_BLOCK);
projectOperations
@@ -4476,9 +4501,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_overriddenInChildProjectWithStricterRequirement() throws Exception {
configSubmitRequirement(
allProjects,
@@ -4520,9 +4542,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_overriddenInChildProjectWithLessStrictRequirement()
throws Exception {
configSubmitRequirement(
@@ -4563,9 +4582,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_overriddenInChildProjectAsDisabled() throws Exception {
configSubmitRequirement(
allProjects,
@@ -4600,9 +4616,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_inheritedFromParentProject() throws Exception {
configSubmitRequirement(
allProjects,
@@ -4631,9 +4644,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_overriddenSRInParentProjectIsInheritedByChildProject()
throws Exception {
// Define submit requirement in root project.
@@ -4679,9 +4689,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
throws Exception {
configSubmitRequirement(
@@ -4723,9 +4730,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_ignoredInChildProject_ifParentAddsSRThatDoesNotAllowOverride()
throws Exception {
// Submit requirement in child project (requires Code-Review=+1)
@@ -4779,9 +4783,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_ignoredInChildProject_ifParentMakesSRNonOverridable()
throws Exception {
configSubmitRequirement(
@@ -4843,9 +4844,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_ignoredInGrandChildProject_ifGrandParentDoesNotAllowOverride()
throws Exception {
configSubmitRequirement(
@@ -4891,9 +4889,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_overrideOverideExpression() throws Exception {
// Define submit requirement in root project.
configSubmitRequirement(
@@ -4953,9 +4948,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_partiallyOverriddenSRIsIgnored() throws Exception {
// Create build-cop-override label
LabelDefinitionInput input = new LabelDefinitionInput();
@@ -5046,11 +5038,9 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- values = {
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
- })
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
public void submitRequirement_storedForClosedChanges() throws Exception {
for (SubmitType submitType : SubmitType.values()) {
Project.NameKey project = createProjectForPush(submitType);
@@ -5094,11 +5084,9 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- values = {
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
- })
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
public void submitRequirement_storedForAbandonedChanges() throws Exception {
for (SubmitType submitType : SubmitType.values()) {
Project.NameKey project = createProjectForPush(submitType);
@@ -5137,11 +5125,9 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- values = {
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
- })
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
public void submitRequirement_retrievedFromNoteDbForAbandonedChanges() throws Exception {
for (SubmitType submitType : SubmitType.values()) {
Project.NameKey project = createProjectForPush(submitType);
@@ -5219,11 +5205,9 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- values = {
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
- })
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
configSubmitRequirement(
project,
@@ -5269,11 +5253,9 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- values = {
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
- })
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
public void
submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
throws Exception {
@@ -5334,9 +5316,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void
submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
throws Exception {
@@ -5390,9 +5369,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
projectOperations
@@ -5445,9 +5421,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
configSubmitRequirement(
project,
@@ -5480,11 +5453,9 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- values = {
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
- })
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
configSubmitRequirement(
project,
@@ -5516,9 +5487,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_applicabilityExpressionIsAlwaysHidden() throws Exception {
configSubmitRequirement(
project,
@@ -5545,38 +5513,6 @@
}
@Test
- public void submitRequirements_notServedIfExperimentNotEnabled() throws Exception {
- configSubmitRequirement(
- project,
- SubmitRequirement.builder()
- .setName("Code-Review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
- .setAllowOverrideInChildProjects(false)
- .build());
-
- PushOneCommit.Result r = createChange();
- String changeId = r.getChangeId();
-
- ChangeInfo change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).isEmpty();
-
- voteLabel(changeId, "Code-Review", -1);
- change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).isEmpty();
-
- voteLabel(changeId, "Code-Review", 2);
- change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).isEmpty();
-
- gApi.changes().id(changeId).current().submit();
- change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).isEmpty();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirements_eliminatesDuplicatesForLegacyNonMatchingSRs() throws Exception {
// If a custom/prolog submit rule emits the same label name multiple times, we merge these into
// a single submit requirement result: in this test, we have two different submit rules that
@@ -5600,9 +5536,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirements_eliminatesDuplicatesForLegacyMatchingSRs() throws Exception {
// If a custom/prolog submit rule emits the same label name multiple times, we merge these into
// a single submit requirement result: in this test, we have two different submit rules that
@@ -5626,9 +5559,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirements_eliminatesMultipleDuplicatesForLegacyMatchingSRs()
throws Exception {
// If a custom/prolog submit rule emits the same label name multiple times, we merge these into
@@ -5655,9 +5585,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_duplicateSubmitRequirement_sameCase() throws Exception {
// Define 2 submit requirements with exact same name but different submittability expression.
try (TestRepository<Repository> repo =
@@ -5708,9 +5635,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_duplicateSubmitRequirement_differentCase() throws Exception {
// Define 2 submit requirements with same name but different case and different submittability
// expression.
@@ -5767,9 +5691,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_overrideInheritedSRWithDifferentNameCasing() throws Exception {
// Define submit requirement in root project and allow override.
configSubmitRequirement(
@@ -5813,9 +5734,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.enabled",
- value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_cannotOverrideNonOverridableInheritedSRWithDifferentNameCasing()
throws Exception {
// Define submit requirement in root project and disallow override.
@@ -5863,6 +5781,235 @@
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE)
+ public void globalSubmitRequirement_storedForClosedChanges() throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ SubmitRequirement.builder()
+ .setName("global-submit-requirement")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+ .setAllowOverrideInChildProjects(false)
+ .build();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(2);
+ assertSubmitRequirementStatus(
+ change.submitRequirements,
+ "global-submit-requirement",
+ Status.UNSATISFIED,
+ /* isLegacy= */ false);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+ voteLabel(changeId, "Code-Review", 2);
+ gApi.changes().id(changeId).topic("test");
+
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(2);
+ assertSubmitRequirementStatus(
+ change.submitRequirements,
+ "global-submit-requirement",
+ Status.SATISFIED,
+ /* isLegacy= */ false);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+ gApi.changes().id(changeId).current().submit();
+
+ ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+ SubmitRequirementResult result =
+ notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+ assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+ assertThat(result.submittabilityExpressionResult().status())
+ .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+ assertThat(result.submittabilityExpressionResult().expression().expressionString())
+ .isEqualTo("topic:test");
+ }
+ }
+
+ @Test
+ public void projectSubmitRequirementDuplicatesGlobal_overrideNotAllowed_globalEvaluated()
+ throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ SubmitRequirement.builder()
+ .setName("CoDe-reView")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+ .setAllowOverrideInChildProjects(false)
+ .build();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ configSubmitRequirement(
+ project,
+ SubmitRequirement.builder()
+ .setName("Code-Review")
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create("label:Code-Review=+2"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ // Vote does not satisfy submit requirement, because the global definition is evaluated.
+ voteLabel(changeId, "CoDe-reView", 2);
+
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(2);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+ // In addition, the legacy submit requirement is emitted, since the status mismatch
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+ // Setting the topic satisfies the global definition.
+ gApi.changes().id(changeId).topic("test");
+
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+ }
+ }
+
+ @Test
+ public void projectSubmitRequirementDuplicatesGlobal_overrideAllowed_projectRequirementEvaluated()
+ throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ SubmitRequirement.builder()
+ .setName("CoDe-reView")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+ .setAllowOverrideInChildProjects(true)
+ .build();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ configSubmitRequirement(
+ project,
+ SubmitRequirement.builder()
+ .setName("Code-Review")
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create("label:Code-Review=+2"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ // Setting the topic does not satisfy submit requirement, because the project definition is
+ // evaluated.
+ gApi.changes().id(changeId).topic("test");
+
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ // There is no mismatch with legacy submit requirement, so the single result is emitted.
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+ // Voting satisfies the project definition.
+ voteLabel(changeId, "Code-Review", 2);
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+ }
+ }
+
+ @Test
+ public void legacySubmitRequirementDuplicatesGlobal_statusMatches_globalReturned()
+ throws Exception {
+ // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+ testLegacySubmitRequirementDuplicatesGlobalStatusMatches(/*allowOverrideInChildProject=*/ true);
+ testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+ /*allowOverrideInChildProject=*/ false);
+ }
+
+ private void testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+ boolean allowOverrideInChildProject) throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ SubmitRequirement.builder()
+ .setName("CoDe-reView")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+ .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+ .build();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ // Both are evaluated, but only the global is returned, since both are unsatisfied
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+ // Both are evaluated, but only the global is returned, since both are satisfied
+ voteLabel(changeId, "Code-Review", 2);
+ gApi.changes().id(changeId).topic("test");
+
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+ }
+ }
+
+ @Test
+ public void legacySubmitRequirementDuplicatesGlobal_statusDoesNotMatch_bothRecordsReturned()
+ throws Exception {
+ // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+ testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+ /*allowOverrideInChildProject=*/ true);
+ testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+ /*allowOverrideInChildProject=*/ false);
+ }
+
+ private void testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+ boolean allowOverrideInChildProject) throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ SubmitRequirement.builder()
+ .setName("CoDe-reView")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+ .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+ .build();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ // Both are evaluated, but only the global is returned, since both are unsatisfied
+ ChangeInfo change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(1);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+ // Both are evaluated and both are returned, since result mismatch
+ voteLabel(changeId, "Code-Review", 2);
+
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(2);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+ gApi.changes().id(changeId).topic("test");
+ gApi.changes().id(changeId).reviewer(admin.id().toString()).deleteVote(LabelId.CODE_REVIEW);
+
+ change = gApi.changes().id(changeId).get();
+ assertThat(change.submitRequirements).hasSize(2);
+ assertThat(change.submitRequirements).hasSize(2);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+ }
+ }
+
+ @Test
public void fourByteEmoji() throws Exception {
// U+1F601 GRINNING FACE WITH SMILING EYES
String smile = new String(Character.toChars(0x1f601));
@@ -6446,7 +6593,7 @@
requirementName,
status,
results.stream()
- .map(r -> String.format("%s=%s", r.name, r.status))
+ .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
.collect(toImmutableList())));
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index 96db71a..6a52eef 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -206,19 +206,19 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- values = {
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD,
- ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS
- })
+ value =
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)
public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
+ rule.numberOfEvaluations.set(0);
PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+
String changeId = r.getChangeId();
- rule.numberOfEvaluations.set(0);
gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
- // Submit rules are invoked
+ // Submit rules are invoked when the change was uploaded, further calls loaded submit records
+ // from the change index.
assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 07a5a9c..ff8a2d0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -34,7 +34,10 @@
import com.google.common.cache.Cache;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
+import com.google.common.collect.MoreCollectors;
import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
@@ -44,12 +47,15 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.client.ChangeKind;
@@ -58,13 +64,18 @@
import com.google.gerrit.extensions.common.FileInfo;
import com.google.gerrit.server.change.ChangeKindCacheImpl;
import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
import com.google.inject.Inject;
import com.google.inject.name.Named;
+import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
+import java.util.stream.Collectors;
import org.eclipse.jgit.lib.ObjectId;
import org.junit.Before;
import org.junit.Test;
@@ -75,6 +86,7 @@
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private ChangeOperations changeOperations;
@Inject private ChangeKindCreator changeKindCreator;
+ @Inject private ExtensionRegistry extensionRegistry;
@Inject
@Named("change_kind")
@@ -260,6 +272,113 @@
}
@Test
+ public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setFunction(LabelFunction.NO_BLOCK));
+ u.save();
+ }
+
+ // This test is covering the backfilling logic for changes which have been submitted, based on
+ // copied approvals, before Gerrit persisted copied votes as Copied-Label footers in NoteDb. It
+ // verifies that for such changes copied approvals are returned from the API even if the copied
+ // votes were not persisted as Copied-Label footers.
+ //
+ // In other words, this test verifies that given a change that was approved by a copied vote and
+ // then submitted and for which the copied approval is not persisted as a Copied-Label footer in
+ // NoteDb the copied approval is backfilled from the corresponding Submitted-With footer that
+ // got written to NoteDb on submit.
+ //
+ // Creating such a change would be possible by running the old Gerrit code from before Gerrit
+ // persisted copied labels as Copied-Label footers. However since this old Gerrit code is no
+ // longer available, the test needs to apply a trick to create a change in this state. It
+ // configures a fake submit rule, that pretends that an approval for a non-sticky label from an
+ // old patch set is still present on the current patch set and allows to submit the change.
+ // Since the label is non-sticky no Copied-Label footer is written for it. On submit the fake
+ // submit rule results in a Submitted-With footer that records the label as approved (although
+ // the label is actually not present on the current patch set). This is exactly the change state
+ // that we would have had by running the old code if submit was based on a copied label. As
+ // result of the backfilling logic we expect that this "copied" label (the label that is
+ // mentioned in the Submitted-With footer) is returned from the API.
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(new TestSubmitRule(user.id()))) {
+ // We want to add a vote on PS1, then not copy it to PS2, but include it in submit records
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ // Vote on patch-set 1
+ vote(admin, changeId, 2, 1);
+ vote(user, changeId, 1, -1);
+
+ // Upload patch-set 2. Change user's "Verified" vote on PS2.
+ changeOperations
+ .change(Change.id(r.getChange().getId().get()))
+ .newPatchset()
+ .file("new_file")
+ .content("content")
+ .commitMessage("Upload PS2")
+ .create();
+ vote(admin, changeId, 2, 1);
+ vote(user, changeId, 1, 1);
+
+ // Upload patch-set 3
+ changeOperations
+ .change(Change.id(r.getChange().getId().get()))
+ .newPatchset()
+ .file("another_file")
+ .content("content")
+ .commitMessage("Upload PS3")
+ .create();
+ vote(admin, changeId, 2, 1);
+
+ List<PatchSetApproval> patchSetApprovals =
+ notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+ .stream()
+ .sorted(comparing(a -> a.patchSetId().get()))
+ .collect(toImmutableList());
+
+ // There's no verified approval on PS#3.
+ assertThat(
+ patchSetApprovals.stream()
+ .filter(
+ a ->
+ a.accountId().equals(user.id())
+ && a.label().equals(TestLabels.verified().getName())
+ && a.patchSetId().get() == 3)
+ .collect(Collectors.toList()))
+ .isEmpty();
+
+ // Submit the change. The TestSubmitRule will store a "submit record" containing a label
+ // voted by user, but the latest patch-set does not have an approval for this user, hence
+ // it will be copied if we request approvals after the change is merged.
+ requestScopeOperations.setApiUser(admin.id());
+ gApi.changes().id(changeId).current().submit();
+
+ patchSetApprovals =
+ notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+ .stream()
+ .sorted(comparing(a -> a.patchSetId().get()))
+ .collect(toImmutableList());
+
+ // Get the copied approval for user on PS3 for the "Verified" label.
+ PatchSetApproval verifiedApproval =
+ patchSetApprovals.stream()
+ .filter(
+ a ->
+ a.accountId().equals(user.id())
+ && a.label().equals(TestLabels.verified().getName())
+ && a.patchSetId().get() == 3)
+ .collect(MoreCollectors.onlyElement());
+
+ assertCopied(
+ verifiedApproval,
+ /* psId= */ 3,
+ TestLabels.verified().getName(),
+ (short) 1,
+ /* copied= */ true);
+ }
+ }
+
+ @Test
public void stickyOnCopyValues() throws Exception {
TestAccount user2 = accountCreator.user2();
@@ -1381,4 +1500,29 @@
assertThat(approval.value()).isEqualTo(value);
assertThat(approval.copied()).isEqualTo(copied);
}
+
+ /**
+ * Test submit rule that always return a passing record with a "Verified" label applied by {@link
+ * TestSubmitRule#userAccountId}.
+ */
+ private static class TestSubmitRule implements SubmitRule {
+ Account.Id userAccountId;
+
+ TestSubmitRule(Account.Id userAccountId) {
+ this.userAccountId = userAccountId;
+ }
+
+ @Override
+ public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+ SubmitRecord record = new SubmitRecord();
+ record.ruleName = "testSubmitRule";
+ record.status = SubmitRecord.Status.OK;
+ SubmitRecord.Label label = new SubmitRecord.Label();
+ label.label = "Verified";
+ label.status = SubmitRecord.Label.Status.OK;
+ label.appliedBy = userAccountId;
+ record.labels = Arrays.asList(label);
+ return Optional.of(record);
+ }
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 8367f60..14a8e98 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,7 +24,6 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -35,7 +34,6 @@
import com.google.gerrit.testing.ConfigSuite;
import com.google.inject.Inject;
import java.util.ArrayDeque;
-import java.util.Map;
import org.apache.commons.lang.RandomStringUtils;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
@@ -144,7 +142,6 @@
gApi.changes().id(id2).current().review(ReviewInput.approve());
gApi.changes().id(id3).current().review(ReviewInput.approve());
- Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
gApi.changes().id(id1).current().submit();
ObjectId subRepoId =
subRepo
@@ -156,25 +153,6 @@
.getObjectId();
expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
-
- // As the submodules have changed commits, the superproject tree will be
- // different, so we cannot directly compare the trees here, so make
- // assumptions only about the changed branches:
- assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
- assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
-
- if ((getSubmitType() == SubmitType.CHERRY_PICK)
- || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
- // each change is updated and the respective target branch is updated:
- assertThat(preview).hasSize(5);
- } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
- // Either the first is used first as is, then the second and third need
- // rebasing, or those two stay as is and the first is rebased.
- // add in 2 master branches, expect 3 or 4:
- assertThat(preview.size()).isAnyOf(3, 4);
- } else {
- assertThat(preview).hasSize(2);
- }
}
@Test
@@ -661,12 +639,6 @@
}
@Test
- public void branchCircularSubscriptionPreview() throws Exception {
- testBranchCircularSubscription(
- changeId -> gApi.changes().id(changeId).current().submitPreview());
- }
-
- @Test
public void projectCircularSubscriptionWholeTopic() throws Exception {
allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 79484ca..f3b13d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -145,7 +145,6 @@
RestCall.get("/changes/%s/revisions/%s/related"),
RestCall.get("/changes/%s/revisions/%s/review"),
RestCall.post("/changes/%s/revisions/%s/review"),
- RestCall.get("/changes/%s/revisions/%s/preview_submit"),
RestCall.post("/changes/%s/revisions/%s/submit"),
RestCall.get("/changes/%s/revisions/%s/submit_type"),
RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index d967f48..317053e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -81,7 +81,6 @@
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.events.ChangeIndexedListener;
import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.webui.UiAction;
@@ -104,10 +103,8 @@
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
-import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import org.eclipse.jgit.diff.DiffFormatter;
@@ -148,162 +145,25 @@
assertThat(projectOperations.project(project).hasHead("master")).isFalse();
PushOneCommit.Result change = createChange();
assertThat(change.getCommit().getParents()).isEmpty();
- Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
assertThat(projectOperations.project(project).hasHead("master")).isFalse();
- assertThat(actual).hasSize(1);
submit(change.getChangeId());
assertThat(projectOperations.project(project).getHead("master").getId())
.isEqualTo(change.getCommit());
- assertTrees(project, actual);
}
@Test
public void submitSingleChange() throws Throwable {
RevCommit initialHead = projectOperations.project(project).getHead("master");
PushOneCommit.Result change = createChange();
- Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
assertThat(headAfterSubmit).isEqualTo(initialHead);
assertRefUpdatedEvents();
assertChangeMergedEvents();
- if ((getSubmitType() == SubmitType.CHERRY_PICK)
- || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
- // The change is updated as well:
- assertThat(actual).hasSize(2);
- } else {
- assertThat(actual).hasSize(1);
- }
-
submit(change.getChangeId());
- assertTrees(project, actual);
- }
-
- @Test
- public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
-
- PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
- submit(change.getChangeId());
-
- RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
- testRepo.reset(initialHead);
- PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
- PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
- PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
- // change 2 is not approved, but we ignore labels
- approve(change3.getChangeId());
-
- try (BinaryResult request =
- gApi.changes().id(change4.getChangeId()).current().submitPreview()) {
- assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
- submit(change4.getChangeId());
- } catch (RestApiException e) {
- switch (getSubmitType()) {
- case FAST_FORWARD_ONLY:
- assertThat(e.getMessage())
- .isEqualTo(
- "Failed to submit 3 changes due to the following problems:\n"
- + "Change "
- + change2.getChange().getId()
- + ": Project policy "
- + "requires all submissions to be a fast-forward. Please "
- + "rebase the change locally and upload again for review.\n"
- + "Change "
- + change3.getChange().getId()
- + ": Project policy "
- + "requires all submissions to be a fast-forward. Please "
- + "rebase the change locally and upload again for review.\n"
- + "Change "
- + change4.getChange().getId()
- + ": Project policy "
- + "requires all submissions to be a fast-forward. Please "
- + "rebase the change locally and upload again for review.");
- break;
- case REBASE_IF_NECESSARY:
- case REBASE_ALWAYS:
- String change2hash = change2.getChange().currentPatchSet().commitId().name();
- assertThat(e.getMessage())
- .isEqualTo(
- "Cannot rebase "
- + change2hash
- + ": The change could "
- + "not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n"
- + "a.txt");
- break;
- case MERGE_ALWAYS:
- case MERGE_IF_NECESSARY:
- case INHERIT:
- assertThat(e.getMessage())
- .isEqualTo(
- "Failed to submit 3 changes due to the following problems:\n"
- + "Change "
- + change2.getChange().getId()
- + ": Change could not be "
- + "merged due to a path conflict. Please rebase the change "
- + "locally and upload the rebased commit for review.\n"
- + "Change "
- + change3.getChange().getId()
- + ": Change could not be "
- + "merged due to a path conflict. Please rebase the change "
- + "locally and upload the rebased commit for review.\n"
- + "Change "
- + change4.getChange().getId()
- + ": Change could not be "
- + "merged due to a path conflict. Please rebase the change "
- + "locally and upload the rebased commit for review.");
- break;
- case CHERRY_PICK:
- default:
- assertWithMessage("Should not reach here.").fail();
- break;
- }
-
- RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
- assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
- assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
- assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
- }
- }
-
- @Test
- public void submitMultipleChangesPreview() throws Throwable {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
- PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
- PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
- // change 2 is not approved, but we ignore labels
- approve(change3.getChangeId());
- Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
- Map<String, Map<String, Integer>> expected = new HashMap<>();
- expected.put(project.get(), new HashMap<>());
- expected.get(project.get()).put("refs/heads/master", 3);
-
- assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
- if (getSubmitType() == SubmitType.CHERRY_PICK) {
- // CherryPick ignores dependencies, thus only change and destination
- // branch refs are modified.
- assertThat(actual).hasSize(2);
- } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
- // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
- // destination branch will be modified.
- assertThat(actual).hasSize(4);
- } else {
- assertThat(actual).hasSize(1);
- }
-
- // check that the submit preview did not actually submit
- RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
- assertThat(headAfterSubmit).isEqualTo(initialHead);
- assertRefUpdatedEvents();
- assertChangeMergedEvents();
-
- // now check we actually have the same content:
- approve(change2.getChangeId());
- submit(change4.getChangeId());
- assertTrees(project, actual);
+ headAfterSubmit = projectOperations.project(project).getHead("master");
+ assertThat(headAfterSubmit).isNotEqualTo(initialHead);
}
/**
@@ -1238,14 +1098,11 @@
assertThat(projectOperations.project(project).hasHead("master")).isFalse();
PushOneCommit.Result change = createChange();
assertThat(change.getCommit().getParents()).isEmpty();
- Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
assertThat(projectOperations.project(project).hasHead("master")).isFalse();
- assertThat(actual).hasSize(1);
submit(change.getChangeId());
assertThat(projectOperations.project(project).getHead("master").getId())
.isEqualTo(change.getCommit());
- assertTrees(project, actual);
}
@Test
@@ -1259,14 +1116,11 @@
change.assertOkStatus();
assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
- Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
assertThat(projectOperations.project(project).hasHead("master")).isFalse();
- assertThat(actual).hasSize(1);
submit(change.getChangeId());
assertThat(projectOperations.project(project).getHead("master").getId())
.isEqualTo(change.getCommit());
- assertTrees(project, actual);
}
private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 157c93c..c4f8f2c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -38,22 +38,10 @@
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.inject.Inject;
-import java.io.File;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
-import java.util.zip.GZIPInputStream;
-import org.apache.commons.compress.archivers.ArchiveStreamFactory;
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.RefSpec;
import org.junit.Test;
@@ -184,8 +172,6 @@
approve(change2b.getChangeId());
approve(change3.getChangeId());
- // get a preview before submitting:
- Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
submit(change1b.getChangeId());
RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -197,23 +183,9 @@
if (isSubmitWholeTopicEnabled()) {
assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-
- // check that the preview matched what happened:
- assertThat(preview).hasSize(3);
-
- assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
- assertTrees(p1, preview);
-
- assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
- assertTrees(p2, preview);
-
- assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
- assertTrees(p3, preview);
} else {
assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
- assertThat(preview).hasSize(1);
- assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
}
}
@@ -281,13 +253,6 @@
+ "merged due to a path conflict. Please rebase the change locally "
+ "and upload the rebased commit for review.";
- // Get a preview before submitting:
- RestApiException thrown =
- assertThrows(
- RestApiException.class,
- () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
- assertThat(thrown.getMessage()).isEqualTo(msg);
-
submitWithConflict(change1b.getChangeId(), msg);
} else {
submit(change1b.getChangeId());
@@ -756,34 +721,4 @@
assertRefUpdatedEvents();
assertChangeMergedEvents();
}
-
- @Test
- public void testPreviewSubmitTgz() throws Throwable {
- Project.NameKey p1 = projectOperations.newProject().create();
-
- TestRepository<?> repo1 = cloneProject(p1);
- PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
- approve(change1.getChangeId());
-
- // get a preview before submitting:
- File tempfile;
- try (BinaryResult request =
- gApi.changes().id(change1.getChangeId()).current().submitPreview("tgz")) {
- assertThat(request.getContentType()).isEqualTo("application/x-gzip");
- tempfile = File.createTempFile("test", null);
- request.writeTo(Files.newOutputStream(tempfile.toPath()));
- }
-
- InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
-
- List<String> untarredFiles = new ArrayList<>();
- try (TarArchiveInputStream tarInputStream =
- (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
- TarArchiveEntry entry;
- while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
- untarredFiles.add(entry.getName());
- }
- }
- assertThat(untarredFiles).containsExactly(p1.get() + ".git");
- }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index a63d60a..0a9a098 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -306,7 +306,10 @@
private void assertChangeSetMergeable(ChangeData change, boolean expected)
throws MissingObjectException, IncorrectObjectTypeException, IOException,
PermissionBackendException {
- ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
+ ChangeSet cs =
+ mergeSuperSet
+ .get()
+ .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index ce92536..fdb2ed7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -37,6 +37,8 @@
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
import org.junit.Before;
import org.junit.Test;
@@ -192,6 +194,26 @@
assertThat(thrown).hasMessageThat().contains("not allowed to delete HEAD");
}
+ @Test
+ public void deleteRefsForBranch() throws Exception {
+ BranchNameKey refsForBranch = BranchNameKey.create(project, "refs/for/master");
+
+ // Creating a branch under refs/for/ is not allowed through the API, hence create it directly in
+ // the remote repo.
+ try (TestRepository<Repository> repo =
+ new TestRepository<>(repoManager.openRepository(project))) {
+ repo.branch(refsForBranch.branch()).commit().message("Initial empty commit").create();
+ }
+
+ assertThat(branch(refsForBranch).get().canDelete).isTrue();
+ String branchRev = branch(refsForBranch).get().revision;
+
+ branch(refsForBranch).delete();
+
+ eventRecorder.assertRefUpdatedEvents(project.get(), refsForBranch.branch(), branchRev, null);
+ assertThrows(ResourceNotFoundException.class, () -> branch(refsForBranch).get());
+ }
+
private void blockForcePush() throws Exception {
projectOperations
.project(project)
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index a97fb49..7e0bce9 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -130,6 +130,8 @@
} else {
assertSubmittedTogether(id1);
assertSubmittedTogether(id2);
+ assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
}
}
@@ -152,6 +154,8 @@
} else {
assertSubmittedTogether(id1);
assertSubmittedTogether(id2);
+ assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
}
}
@@ -180,6 +184,9 @@
assertSubmittedTogether(id1);
assertSubmittedTogether(id2);
assertSubmittedTogether(id3, id3, id2);
+ assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id3, id3, id2, id1);
}
}
@@ -227,6 +234,13 @@
assertSubmittedTogether(id4, id4, id3, id2);
assertSubmittedTogether(id5);
assertSubmittedTogether(id6, id6, id5);
+
+ assertSubmittedTogetherWithTopicClosure(id1, id6, id5, id3, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id6, id5, id2);
+ assertSubmittedTogetherWithTopicClosure(id3, id6, id5, id3, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id4, id6, id5, id4, id3, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id5);
+ assertSubmittedTogetherWithTopicClosure(id6, id6, id5, id2);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index a23fb7b..fb51ae0 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -21,6 +21,8 @@
import com.google.common.collect.MoreCollectors;
import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -40,6 +42,7 @@
import com.google.gerrit.server.query.change.InternalChangeQuery;
import com.google.inject.Inject;
import com.google.inject.Provider;
+import java.util.Map;
import java.util.Optional;
import org.junit.Before;
import org.junit.Test;
@@ -49,6 +52,7 @@
@Inject SubmitRequirementsEvaluator evaluator;
@Inject private ProjectOperations projectOperations;
@Inject private Provider<InternalChangeQuery> changeQueryProvider;
+ @Inject private ExtensionRegistry extensionRegistry;
private ChangeData changeData;
private String changeId;
@@ -110,6 +114,93 @@
}
@Test
+ public void globalSubmitRequirementEvaluated() throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ createSubmitRequirement(
+ /*name=*/ "global-config-requirement",
+ /* applicabilityExpr= */ "project:" + project.get(),
+ /*submittabilityExpr= */ "is:true",
+ /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+ false);
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ SubmitRequirement projectSubmitRequirement =
+ createSubmitRequirement(
+ /*name=*/ "project-config-requirement",
+ /* applicabilityExpr= */ "project:" + project.get(),
+ /*submittabilityExpr= */ "is:true",
+ /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+ false);
+ configSubmitRequirement(project, projectSubmitRequirement);
+ Map<SubmitRequirement, SubmitRequirementResult> results =
+ evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+ assertThat(results).hasSize(2);
+ assertThat(results.get(globalSubmitRequirement).status())
+ .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+ assertThat(results.get(projectSubmitRequirement).status())
+ .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+ }
+ }
+
+ @Test
+ public void
+ globalSubmitRequirement_duplicateInProjectConfig_overrideAllowed_projectResultReturned()
+ throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ createSubmitRequirement(
+ /*name=*/ "config-requirement",
+ /* applicabilityExpr= */ "project:" + project.get(),
+ /*submittabilityExpr= */ "is:true",
+ /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+ true);
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ SubmitRequirement projectSubmitRequirement =
+ createSubmitRequirement(
+ /*name=*/ "config-requirement",
+ /* applicabilityExpr= */ "project:" + project.get(),
+ /*submittabilityExpr= */ "is:true",
+ /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+ false);
+ configSubmitRequirement(project, projectSubmitRequirement);
+ Map<SubmitRequirement, SubmitRequirementResult> results =
+ evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+ assertThat(results).hasSize(1);
+ assertThat(results.get(projectSubmitRequirement).status())
+ .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+ }
+ }
+
+ @Test
+ public void
+ globalSubmitRequirement_duplicateInProjectConfig_overrideNotAllowedAllowed_globalResultReturned()
+ throws Exception {
+ SubmitRequirement globalSubmitRequirement =
+ createSubmitRequirement(
+ /*name=*/ "config-requirement",
+ /* applicabilityExpr= */ "project:" + project.get(),
+ /*submittabilityExpr= */ "is:true",
+ /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+ false);
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+ SubmitRequirement projectSubmitRequirement =
+ createSubmitRequirement(
+ /*name=*/ "config-requirement",
+ /* applicabilityExpr= */ "project:" + project.get(),
+ /*submittabilityExpr= */ "is:true",
+ /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+ false);
+ configSubmitRequirement(project, projectSubmitRequirement);
+ Map<SubmitRequirement, SubmitRequirementResult> results =
+ evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+ assertThat(results).hasSize(1);
+ assertThat(results.get(globalSubmitRequirement).status())
+ .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+ }
+ }
+
+ @Test
public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
throws Exception {
SubmitRequirement sr =
@@ -292,13 +383,27 @@
@Nullable String applicabilityExpr,
String submittabilityExpr,
@Nullable String overrideExpr) {
+ return createSubmitRequirement(
+ /*name= */ "sr-name",
+ applicabilityExpr,
+ submittabilityExpr,
+ overrideExpr,
+ /*allowOverrideInChildProjects=*/ false);
+ }
+
+ private SubmitRequirement createSubmitRequirement(
+ String name,
+ @Nullable String applicabilityExpr,
+ String submittabilityExpr,
+ @Nullable String overrideExpr,
+ boolean allowOverrideInChildProjects) {
return SubmitRequirement.builder()
- .setName("sr-name")
+ .setName(name)
.setDescription(Optional.of("sr-description"))
.setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
.setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
.setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
- .setAllowOverrideInChildProjects(false)
+ .setAllowOverrideInChildProjects(allowOverrideInChildProjects)
.build();
}
}
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 05eb6e0..c6b8a33 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -180,6 +180,43 @@
}
@Test
+ public void defaultSubmitRule_withNonExistingLabel() throws Exception {
+ SubmitRecord submitRecord =
+ createSubmitRecord(
+ "gerrit~DefaultSubmitRule",
+ Status.OK,
+ Arrays.asList(createLabel("Non-Existing", Label.Status.OK)));
+
+ List<SubmitRequirementResult> requirements =
+ SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+ assertThat(requirements).isEmpty();
+ }
+
+ @Test
+ public void defaultSubmitRule_withExistingAndNonExistingLabels() throws Exception {
+ SubmitRecord submitRecord =
+ createSubmitRecord(
+ "gerrit~DefaultSubmitRule",
+ Status.OK,
+ Arrays.asList(
+ createLabel("Non-Existing", Label.Status.OK),
+ createLabel("Code-Review", Label.Status.OK)));
+
+ List<SubmitRequirementResult> requirements =
+ SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+ // The "Non-Existing" label was skipped since it does not exist in the project config.
+ assertThat(requirements).hasSize(1);
+ assertResult(
+ requirements.get(0),
+ /* reqName= */ "Code-Review",
+ /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+ SubmitRequirementResult.Status.SATISFIED,
+ SubmitRequirementExpressionResult.Status.PASS);
+ }
+
+ @Test
public void customSubmitRule_noLabels_withStatusOk() {
SubmitRecord submitRecord =
createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
diff --git a/plugins/replication b/plugins/replication
index af3dee4..d9c59a0 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit af3dee4dc18c47a60ab7a97333e87c98b8379173
+Subproject commit d9c59a0be1c423706b2c4c74c955ebbeca5a894d
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index f5bbf00..6673cdf 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -187,11 +187,11 @@
export class MyCustomElement extends ...{
constructor() {
super(); //This is mandatory to call parent constructor
- this._userService = appContext.userService;
+ this._userModel = appContext.userModel;
}
//...
_getUserName() {
- return this._userService.activeUserName();
+ return this._userModel.activeUserName();
}
}
```
@@ -203,12 +203,12 @@
export class MyCustomElement extends ...{
created() {
// Incorrect: assign all dependencies in the constructor
- this._userService = appContext.userService;
+ this._userModel = appContext.userModel;
}
//...
_getUserName() {
// Incorrect: use appContext outside of a constructor
- return appContext.userService.activeUserName();
+ return appContext.userModel.activeUserName();
}
}
```
@@ -237,7 +237,7 @@
constructor() {
super();
// Assign services here
- this._userService = appContext.userService;
+ this._userModel = appContext.userModel;
// Code from the created method - put it before existing actions in constructor
createdAction1();
createdAction2();
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0029f5c..def693d 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -100,4 +100,6 @@
ATTENTION_SET_CHIP = 'attention-set-chip',
SAVE_COMMENT = 'save-comment',
COMMENT_SAVED = 'comment-saved',
+ DISCARD_COMMENT = 'discard-comment',
+ COMMENT_DISCARDED = 'comment-discarded',
}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 037e11f..88ade26 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -29,7 +29,6 @@
} from '../../../types/common';
import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
import {getAppContext} from '../../../services/app-context';
-import {serverConfig$} from '../../../services/config/config-model';
import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
@@ -77,6 +76,8 @@
private readonly restApiService = getAppContext().restApiService;
+ private readonly configModel = getAppContext().configModel;
+
constructor() {
super();
this.query = (input: string) => this.getRepoBranchesSuggestions(input);
@@ -86,7 +87,7 @@
super.connectedCallback();
if (!this.repoName) return;
- subscribe(this, serverConfig$, config => {
+ subscribe(this, this.configModel.serverConfig$, config => {
this.privateChangesEnabled =
config?.change?.disable_private_changes ?? false;
});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 7e04281..cf5d952 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -115,7 +115,8 @@
@property({type: Boolean})
_loading = true;
- private originalInheritsFrom?: ProjectInfo;
+ // private but used in the tests
+ originalInheritsFrom?: ProjectInfo;
private readonly restApiService = getAppContext().restApiService;
@@ -131,7 +132,7 @@
this._modified = true;
}
- _repoChanged(repo: RepoName) {
+ _repoChanged(repo?: RepoName) {
this._loading = true;
if (!repo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
similarity index 65%
rename from polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
rename to polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index 1ccfd5e..a5159ae 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -15,23 +15,42 @@
* limitations under the License.
*/
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-access.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {toSortedPermissionsArray} from '../../../utils/access-util.js';
+import '../../../test/common-test-setup-karma';
+import './gr-repo-access';
+import {GrRepoAccess} from './gr-repo-access';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {toSortedPermissionsArray} from '../../../utils/access-util';
import {
addListenerForTest,
mockPromise,
+ queryAll,
+ queryAndAssert,
stubRestApi,
-} from '../../../test/test-utils.js';
+} from '../../../test/test-utils';
+import {
+ ChangeInfo,
+ GitRef,
+ RepoName,
+ UrlEncodedRepoName,
+} from '../../../types/common';
+import {PermissionAction} from '../../../constants/constants';
+import {PageErrorEvent} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+ AutocompleteCommitEvent,
+ GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrAccessSection} from '../gr-access-section/gr-access-section';
+import {GrPermission} from '../gr-permission/gr-permission';
+import {createChange} from '../../../test/test-data-generators';
const basicFixture = fixtureFromElement('gr-repo-access');
suite('gr-repo-access tests', () => {
- let element;
+ let element: GrRepoAccess;
- let repoStub;
+ let repoStub: sinon.SinonStub;
const accessRes = {
local: {
@@ -39,13 +58,13 @@
permissions: {
owner: {
rules: {
- 234: {action: 'ALLOW'},
- 123: {action: 'DENY'},
+ 234: {action: PermissionAction.ALLOW},
+ 123: {action: PermissionAction.DENY},
},
},
read: {
rules: {
- 234: {action: 'ALLOW'},
+ 234: {action: PermissionAction.ALLOW},
},
},
},
@@ -59,11 +78,13 @@
name: 'Maintainers',
},
},
- config_web_links: [{
- name: 'gitiles',
- target: '_blank',
- url: 'https://my/site/+log/123/project.config',
- }],
+ config_web_links: [
+ {
+ name: 'gitiles',
+ target: '_blank',
+ url: 'https://my/site/+log/123/project.config',
+ },
+ ],
can_upload: true,
};
const accessRes2 = {
@@ -73,7 +94,7 @@
accessDatabase: {
rules: {
group1: {
- action: 'ALLOW',
+ action: PermissionAction.ALLOW,
},
},
},
@@ -82,15 +103,17 @@
},
};
const repoRes = {
+ id: '' as UrlEncodedRepoName,
labels: {
'Code-Review': {
values: {
- ' 0': 'No score',
+ '0': 'No score',
'-1': 'I would prefer this is not merged as is',
'-2': 'This shall not be merged',
'+1': 'Looks good to me, but someone else must approve',
'+2': 'Looks good to me, approved',
},
+ default_value: 0,
},
},
};
@@ -106,7 +129,7 @@
};
setup(async () => {
element = basicFixture.instantiate();
- stubRestApi('getAccount').returns(Promise.resolve(null));
+ stubRestApi('getAccount').returns(Promise.resolve(undefined));
repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
element._loading = false;
element._ownerOf = [];
@@ -115,43 +138,51 @@
});
test('_repoChanged called when repo name changes', async () => {
- sinon.stub(element, '_repoChanged');
- element.repo = 'New Repo';
+ const repoChangedStub = sinon.stub(element, '_repoChanged');
+ element.repo = 'New Repo' as RepoName;
await flush();
- assert.isTrue(element._repoChanged.called);
+ assert.isTrue(repoChangedStub.called);
});
test('_repoChanged', async () => {
- const accessStub = stubRestApi(
- 'getRepoAccessRights');
+ const accessStub = stubRestApi('getRepoAccessRights');
- accessStub.withArgs('New Repo').returns(
- Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
- accessStub.withArgs('Another New Repo')
- .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
- const capabilitiesStub = stubRestApi(
- 'getCapabilities');
+ accessStub
+ .withArgs('New Repo' as RepoName)
+ .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+ accessStub
+ .withArgs('Another New Repo' as RepoName)
+ .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+ const capabilitiesStub = stubRestApi('getCapabilities');
capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
- await element._repoChanged('New Repo');
+ await element._repoChanged('New Repo' as RepoName);
assert.isTrue(accessStub.called);
assert.isTrue(capabilitiesStub.called);
assert.isTrue(repoStub.called);
assert.isNotOk(element._inheritsFrom);
assert.deepEqual(element._local, accessRes.local);
- assert.deepEqual(element._sections,
- toSortedPermissionsArray(accessRes.local));
+ assert.deepEqual(
+ element._sections,
+ toSortedPermissionsArray(accessRes.local)
+ );
assert.deepEqual(element._labels, repoRes.labels);
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('.weblinks')).display,
- 'block');
+ assert.equal(
+ getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+ .display,
+ 'block'
+ );
- await element._repoChanged('Another New Repo');
- assert.deepEqual(element._sections,
- toSortedPermissionsArray(accessRes2.local));
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('.weblinks')).display,
- 'none');
+ await element._repoChanged('Another New Repo' as RepoName);
+ assert.deepEqual(
+ element._sections,
+ toSortedPermissionsArray(accessRes2.local)
+ );
+ assert.equal(
+ getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+ .display,
+ 'none'
+ );
});
test('_repoChanged when repo changes to undefined returns', async () => {
@@ -161,10 +192,12 @@
name: 'Access Database',
},
};
- const accessStub = stubRestApi('getRepoAccessRights')
- .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
- const capabilitiesStub = stubRestApi(
- 'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+ const accessStub = stubRestApi('getRepoAccessRights').returns(
+ Promise.resolve(JSON.parse(JSON.stringify(accessRes2)))
+ );
+ const capabilitiesStub = stubRestApi('getCapabilities').returns(
+ Promise.resolve(capabilitiesRes)
+ );
await element._repoChanged();
assert.isFalse(accessStub.called);
@@ -173,34 +206,39 @@
});
test('_computeParentHref', () => {
- const repoName = 'test-repo';
- assert.equal(element._computeParentHref(repoName),
- '/admin/repos/test-repo,access');
+ assert.equal(
+ element._computeParentHref('test-repo' as RepoName),
+ '/admin/repos/test-repo,access'
+ );
});
test('_computeMainClass', () => {
- let ownerOf = ['refs/*'];
+ let ownerOf = ['refs/*'] as GitRef[];
const editing = true;
const canUpload = false;
- assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
- assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
- 'admin editing');
+ assert.equal(element._computeMainClass(ownerOf, canUpload, false), 'admin');
+ assert.equal(
+ element._computeMainClass(ownerOf, canUpload, editing),
+ 'admin editing'
+ );
ownerOf = [];
- assert.equal(element._computeMainClass(ownerOf, canUpload), '');
- assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
- 'editing');
+ assert.equal(element._computeMainClass(ownerOf, canUpload, false), '');
+ assert.equal(
+ element._computeMainClass(ownerOf, canUpload, editing),
+ 'editing'
+ );
});
test('inherit section', async () => {
element._local = {};
element._ownerOf = [];
- sinon.stub(element, '_computeParentHref');
+ const computeParentHrefStub = sinon.stub(element, '_computeParentHref');
await flush();
// Nothing should appear when no inherit from and not in edit mode.
assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
// The autocomplete should be hidden, and the link should be displayed.
- assert.isFalse(element._computeParentHref.called);
+ assert.isFalse(computeParentHrefStub.called);
// When in edit mode, the autocomplete should appear.
element._editing = true;
// When editing, the autocomplete should still not be shown.
@@ -208,33 +246,45 @@
element._editing = false;
element._inheritsFrom = {
- id: '1234',
- name: 'another-repo',
+ id: '1234' as UrlEncodedRepoName,
+ name: 'another-repo' as RepoName,
};
await flush();
// When there is a parent project, the link should be displayed.
assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
- assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
- 'none');
- assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
- 'none');
- assert.isTrue(element._computeParentHref.called);
+ assert.notEqual(
+ getComputedStyle(element.$.inheritFromName).display,
+ 'none'
+ );
+ assert.equal(
+ getComputedStyle(
+ queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+ ).display,
+ 'none'
+ );
+ assert.isTrue(computeParentHrefStub.called);
element._editing = true;
// When editing, the autocomplete should be shown.
assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
- assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
- 'none');
+ assert.notEqual(
+ getComputedStyle(
+ queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+ ).display,
+ 'none'
+ );
});
test('_handleUpdateInheritFrom', async () => {
- element._inheritFromFilter = 'foo bar baz';
- element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+ element._inheritFromFilter = 'foo bar baz' as RepoName;
+ element._handleUpdateInheritFrom({
+ detail: {value: 'abc+123'},
+ } as CustomEvent);
await flush();
assert.isOk(element._inheritsFrom);
- assert.equal(element._inheritsFrom.id, 'abc+123');
- assert.equal(element._inheritsFrom.name, 'foo bar baz');
+ assert.equal(element._inheritsFrom!.id, 'abc+123');
+ assert.equal(element._inheritsFrom!.name, 'foo bar baz' as RepoName);
});
test('_computeLoadingClass', () => {
@@ -243,84 +293,113 @@
});
test('fires page-error', async () => {
- const response = {status: 404};
+ const response = {status: 404} as Response;
- stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
- errFn(response);
+ stubRestApi('getRepoAccessRights').callsFake((_repoName, errFn) => {
+ if (errFn !== undefined) {
+ errFn(response);
+ }
return Promise.resolve(undefined);
});
const promise = mockPromise();
addListenerForTest(document, 'page-error', e => {
- assert.deepEqual(e.detail.response, response);
+ assert.deepEqual((e as PageErrorEvent).detail.response, response);
promise.resolve();
});
- element.repo = 'test';
+ element.repo = 'test' as RepoName;
await promise;
});
suite('with defined sections', () => {
const testEditSaveCancelBtns = async (
- shouldShowSave,
- shouldShowSaveReview
+ shouldShowSave: boolean,
+ shouldShowSaveReview: boolean
) => {
// Edit button is visible and Save button is hidden.
- assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
- assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
- assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
- assert.equal(element.$.editBtn.innerText, 'EDIT');
assert.equal(
- getComputedStyle(element.$.editInheritFromInput).display,
- 'none'
+ getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+ .display,
+ 'none'
+ );
+ assert.equal(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn')).display,
+ 'none'
+ );
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn')).display,
+ 'none'
+ );
+ assert.equal(
+ queryAndAssert<GrButton>(element, '#editBtn').innerText,
+ 'EDIT'
+ );
+ assert.equal(
+ getComputedStyle(
+ queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+ ).display,
+ 'none'
);
element._inheritsFrom = {
- id: 'test-project',
+ id: 'test-project' as UrlEncodedRepoName,
};
await flush();
assert.equal(
- getComputedStyle(
- element.shadowRoot.querySelector('#editInheritFromInput')
- ).display,
- 'none'
+ getComputedStyle(
+ queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+ ).display,
+ 'none'
);
- MockInteractions.tap(element.$.editBtn);
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#editBtn'));
await flush();
// Edit button changes to Cancel button, and Save button is visible but
// disabled.
- assert.equal(element.$.editBtn.innerText, 'CANCEL');
+ assert.equal(
+ queryAndAssert<GrButton>(element, '#editBtn').innerText,
+ 'CANCEL'
+ );
if (shouldShowSaveReview) {
assert.notEqual(
- getComputedStyle(element.$.saveReviewBtn).display,
- 'none'
+ getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+ .display,
+ 'none'
);
- assert.isTrue(element.$.saveReviewBtn.disabled);
+ assert.isTrue(
+ queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+ );
}
if (shouldShowSave) {
- assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
- assert.isTrue(element.$.saveBtn.disabled);
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn'))
+ .display,
+ 'none'
+ );
+ assert.isTrue(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
}
assert.notEqual(
- getComputedStyle(
- element.shadowRoot.querySelector('#editInheritFromInput')
- ).display,
- 'none'
+ getComputedStyle(
+ queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+ ).display,
+ 'none'
);
// Save button should be enabled after access is modified
element.dispatchEvent(
- new CustomEvent('access-modified', {
- composed: true,
- bubbles: true,
- })
+ new CustomEvent('access-modified', {
+ composed: true,
+ bubbles: true,
+ })
);
if (shouldShowSaveReview) {
- assert.isFalse(element.$.saveReviewBtn.disabled);
+ assert.isFalse(
+ queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+ );
}
if (shouldShowSave) {
- assert.isFalse(element.$.saveBtn.disabled);
+ assert.isFalse(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
}
};
@@ -337,16 +416,20 @@
});
test('removing an added section', async () => {
- element.editing = true;
+ element._editing = true;
await flush();
- assert.equal(element._sections.length, 1);
- element.shadowRoot
- .querySelector('gr-access-section').dispatchEvent(
- new CustomEvent('added-section-removed', {
- composed: true, bubbles: true,
- }));
+ assert.equal(element._sections!.length, 1);
+ queryAndAssert<GrAccessSection>(
+ element,
+ 'gr-access-section'
+ ).dispatchEvent(
+ new CustomEvent('added-section-removed', {
+ composed: true,
+ bubbles: true,
+ })
+ );
await flush();
- assert.equal(element._sections.length, 0);
+ assert.equal(element._sections!.length, 0);
});
test('button visibility for non ref owner', () => {
@@ -354,64 +437,77 @@
assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
});
- test('button visibility for non ref owner with upload privilege',
- async () => {
- element._canUpload = true;
- await flush();
- testEditSaveCancelBtns(false, true);
- });
+ test('button visibility for non ref owner with upload privilege', async () => {
+ element._canUpload = true;
+ await flush();
+ testEditSaveCancelBtns(false, true);
+ });
test('button visibility for ref owner', async () => {
- element._ownerOf = ['refs/for/*'];
+ element._ownerOf = ['refs/for/*'] as GitRef[];
await flush();
testEditSaveCancelBtns(true, false);
});
test('button visibility for ref owner and upload', async () => {
- element._ownerOf = ['refs/for/*'];
+ element._ownerOf = ['refs/for/*'] as GitRef[];
element._canUpload = true;
await flush();
testEditSaveCancelBtns(true, false);
});
test('_handleAccessModified called with event fired', async () => {
- sinon.spy(element, '_handleAccessModified');
+ const handleAccessModifiedSpy = sinon.spy(
+ element,
+ '_handleAccessModified'
+ );
element.dispatchEvent(
- new CustomEvent('access-modified', {
- composed: true, bubbles: true,
- }));
+ new CustomEvent('access-modified', {
+ composed: true,
+ bubbles: true,
+ })
+ );
await flush();
- assert.isTrue(element._handleAccessModified.called);
+ assert.isTrue(handleAccessModifiedSpy.called);
});
test('_handleAccessModified called when parent changes', async () => {
element._inheritsFrom = {
- id: 'test-project',
+ id: 'test-project' as UrlEncodedRepoName,
};
await flush();
- element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
- new CustomEvent('commit', {
- detail: {},
- composed: true, bubbles: true,
- }));
- sinon.spy(element, '_handleAccessModified');
+ queryAndAssert<GrAutocomplete>(
+ element,
+ '#editInheritFromInput'
+ ).dispatchEvent(
+ new CustomEvent('commit', {
+ detail: {},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ const handleAccessModifiedSpy = sinon.spy(
+ element,
+ '_handleAccessModified'
+ );
element.dispatchEvent(
- new CustomEvent('access-modified', {
- detail: {},
- composed: true, bubbles: true,
- }));
+ new CustomEvent('access-modified', {
+ detail: {},
+ composed: true,
+ bubbles: true,
+ })
+ );
await flush();
- assert.isTrue(element._handleAccessModified.called);
+ assert.isTrue(handleAccessModifiedSpy.called);
});
test('_handleSaveForReview', async () => {
- const saveStub =
- stubRestApi('setRepoAccessRightsForReview');
+ const saveStub = stubRestApi('setRepoAccessRightsForReview');
sinon.stub(element, '_computeAddAndRemove').returns({
add: {},
remove: {},
});
- element._handleSaveForReview();
+ element._handleSaveForReview(new Event('test'));
await flush();
assert.isFalse(saveStub.called);
});
@@ -522,29 +618,35 @@
test('_handleSaveForReview parent change', async () => {
element._inheritsFrom = {
- id: 'test-project',
+ id: 'test-project' as UrlEncodedRepoName,
};
- element._originalInheritsFrom = {
- id: 'test-project-original',
+ element.originalInheritsFrom = {
+ id: 'test-project-original' as UrlEncodedRepoName,
};
await flush();
assert.deepEqual(element._computeAddAndRemove(), {
- parent: 'test-project', add: {}, remove: {},
+ parent: 'test-project',
+ add: {},
+ remove: {},
});
});
test('_handleSaveForReview new parent with spaces', async () => {
- element._inheritsFrom = {id: 'spaces+in+project+name'};
- element._originalInheritsFrom = {id: 'old-project'};
+ element._inheritsFrom = {
+ id: 'spaces+in+project+name' as UrlEncodedRepoName,
+ };
+ element.originalInheritsFrom = {id: 'old-project' as UrlEncodedRepoName};
await flush();
assert.deepEqual(element._computeAddAndRemove(), {
- parent: 'spaces in project name', add: {}, remove: {},
+ parent: 'spaces in project name',
+ add: {},
+ remove: {},
});
});
test('_handleSaveForReview rules', async () => {
// Delete a rule.
- element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+ element._local!['refs/*'].permissions.owner.rules[123].deleted = true;
await flush();
let expectedInput = {
add: {},
@@ -563,10 +665,10 @@
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Undo deleting a rule.
- delete element._local['refs/*'].permissions.owner.rules[123].deleted;
+ delete element._local!['refs/*'].permissions.owner.rules[123].deleted;
// Modify a rule.
- element._local['refs/*'].permissions.owner.rules[123].modified = true;
+ element._local!['refs/*'].permissions.owner.rules[123].modified = true;
await flush();
expectedInput = {
add: {
@@ -597,7 +699,9 @@
test('_computeAddAndRemove permissions', async () => {
// Add a new rule to a permission.
- let expectedInput = {
+ let expectedInput = {};
+
+ expectedInput = {
add: {
'refs/*': {
permissions: {
@@ -614,22 +718,27 @@
},
remove: {},
};
-
- element.shadowRoot
- .querySelector('gr-access-section').shadowRoot
- .querySelector('gr-permission')
- ._handleAddRuleItem(
- {detail: {value: 'Maintainers'}});
+ const grAccessSection = queryAndAssert<GrAccessSection>(
+ element,
+ 'gr-access-section'
+ );
+ queryAndAssert<GrPermission>(
+ grAccessSection,
+ 'gr-permission'
+ )._handleAddRuleItem({
+ detail: {value: 'Maintainers'},
+ } as AutocompleteCommitEvent);
await flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Remove the added rule.
- delete element._local['refs/*'].permissions.owner.rules.Maintainers;
+ delete element._local!['refs/*'].permissions.owner.rules.Maintainers;
// Delete a permission.
- element._local['refs/*'].permissions.owner.deleted = true;
+ element._local!['refs/*'].permissions.owner.deleted = true;
await flush();
+
expectedInput = {
add: {},
remove: {
@@ -643,10 +752,10 @@
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Undo delete permission.
- delete element._local['refs/*'].permissions.owner.deleted;
+ delete element._local!['refs/*'].permissions.owner.deleted;
// Modify a permission.
- element._local['refs/*'].permissions.owner.modified = true;
+ element._local!['refs/*'].permissions.owner.modified = true;
await flush();
expectedInput = {
add: {
@@ -675,7 +784,9 @@
test('_computeAddAndRemove sections', async () => {
// Add a new permission to a section
- let expectedInput = {
+ let expectedInput = {};
+
+ expectedInput = {
add: {
'refs/*': {
permissions: {
@@ -689,8 +800,10 @@
},
remove: {},
};
- element.shadowRoot
- .querySelector('gr-access-section')._handleAddPermission();
+ queryAndAssert<GrAccessSection>(
+ element,
+ 'gr-access-section'
+ )._handleAddPermission();
await flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
@@ -716,18 +829,23 @@
},
remove: {},
};
- const newPermission =
- dom(element.shadowRoot
- .querySelector('gr-access-section').root).querySelectorAll(
- 'gr-permission')[2];
- newPermission._handleAddRuleItem(
- {detail: {value: 'Maintainers'}});
+ const grAccessSection = queryAndAssert<GrAccessSection>(
+ element,
+ 'gr-access-section'
+ );
+ const newPermission = queryAll<GrPermission>(
+ grAccessSection,
+ 'gr-permission'
+ )[2];
+ newPermission._handleAddRuleItem({
+ detail: {value: 'Maintainers'},
+ } as AutocompleteCommitEvent);
await flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Modify a section reference.
- element._local['refs/*'].updatedId = 'refs/for/bar';
- element._local['refs/*'].modified = true;
+ element._local!['refs/*'].updatedId = 'refs/for/bar';
+ element._local!['refs/*'].modified = true;
await flush();
expectedInput = {
add: {
@@ -735,13 +853,13 @@
modified: true,
updatedId: 'refs/for/bar',
permissions: {
- 'owner': {
+ owner: {
rules: {
234: {action: 'ALLOW'},
123: {action: 'DENY'},
},
},
- 'read': {
+ read: {
rules: {
234: {action: 'ALLOW'},
},
@@ -771,7 +889,7 @@
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Delete a section.
- element._local['refs/*'].deleted = true;
+ element._local!['refs/*'].deleted = true;
await flush();
expectedInput = {
add: {},
@@ -786,7 +904,9 @@
test('_computeAddAndRemove new section', async () => {
// Add a new permission to a section
- let expectedInput = {
+ let expectedInput = {};
+
+ expectedInput = {
add: {
'refs/for/*': {
added: true,
@@ -814,8 +934,10 @@
},
remove: {},
};
- const newSection = dom(element.root)
- .querySelectorAll('gr-access-section')[1];
+ const newSection = queryAll<GrAccessSection>(
+ element,
+ 'gr-access-section'
+ )[1];
newSection._handleAddPermission();
await flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
@@ -844,14 +966,17 @@
remove: {},
};
- newSection.shadowRoot
- .querySelector('gr-permission')._handleAddRuleItem(
- {detail: {value: 'Maintainers'}});
+ queryAndAssert<GrPermission>(
+ newSection,
+ 'gr-permission'
+ )._handleAddRuleItem({
+ detail: {value: 'Maintainers'},
+ } as AutocompleteCommitEvent);
await flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Modify a the reference from the default value.
- element._local['refs/for/*'].updatedId = 'refs/for/new';
+ element._local!['refs/for/*'].updatedId = 'refs/for/new';
await flush();
expectedInput = {
add: {
@@ -881,10 +1006,12 @@
test('_computeAddAndRemove combinations', async () => {
// Modify rule and delete permission that it is inside of.
- element._local['refs/*'].permissions.owner.rules[123].modified = true;
- element._local['refs/*'].permissions.owner.deleted = true;
+ element._local!['refs/*'].permissions.owner.rules[123].modified = true;
+ element._local!['refs/*'].permissions.owner.deleted = true;
await flush();
- let expectedInput = {
+ let expectedInput = {};
+
+ expectedInput = {
add: {},
remove: {
'refs/*': {
@@ -896,13 +1023,13 @@
};
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Delete rule and delete permission that it is inside of.
- element._local['refs/*'].permissions.owner.rules[123].modified = false;
- element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+ element._local!['refs/*'].permissions.owner.rules[123].modified = false;
+ element._local!['refs/*'].permissions.owner.rules[123].deleted = true;
await flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Also modify a different rule inside of another permission.
- element._local['refs/*'].permissions.read.modified = true;
+ element._local!['refs/*'].permissions.read.modified = true;
await flush();
expectedInput = {
add: {
@@ -929,10 +1056,10 @@
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Modify both permissions with an exclusive bit. Owner is still
// deleted.
- element._local['refs/*'].permissions.owner.exclusive = true;
- element._local['refs/*'].permissions.owner.modified = true;
- element._local['refs/*'].permissions.read.exclusive = true;
- element._local['refs/*'].permissions.read.modified = true;
+ element._local!['refs/*'].permissions.owner.exclusive = true;
+ element._local!['refs/*'].permissions.owner.modified = true;
+ element._local!['refs/*'].permissions.read.exclusive = true;
+ element._local!['refs/*'].permissions.read.modified = true;
await flush();
expectedInput = {
add: {
@@ -960,12 +1087,17 @@
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Add a rule to the existing permission;
- const readPermission =
- dom(element.shadowRoot
- .querySelector('gr-access-section').root).querySelectorAll(
- 'gr-permission')[1];
- readPermission._handleAddRuleItem(
- {detail: {value: 'Maintainers'}});
+ const grAccessSection = queryAndAssert<GrAccessSection>(
+ element,
+ 'gr-access-section'
+ );
+ const readPermission = queryAll<GrPermission>(
+ grAccessSection,
+ 'gr-permission'
+ )[1];
+ readPermission._handleAddRuleItem({
+ detail: {value: 'Maintainers'},
+ } as AutocompleteCommitEvent);
await flush();
expectedInput = {
@@ -995,8 +1127,8 @@
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Change one of the refs
- element._local['refs/*'].updatedId = 'refs/for/bar';
- element._local['refs/*'].modified = true;
+ element._local!['refs/*'].updatedId = 'refs/for/bar';
+ element._local!['refs/*'].modified = true;
await flush();
expectedInput = {
@@ -1032,21 +1164,26 @@
},
},
};
- element._local['refs/*'].deleted = true;
+ element._local!['refs/*'].deleted = true;
await flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Add a new section.
MockInteractions.tap(element.$.addReferenceBtn);
- let newSection = dom(element.root)
- .querySelectorAll('gr-access-section')[1];
+ let newSection = queryAll<GrAccessSection>(
+ element,
+ 'gr-access-section'
+ )[1];
newSection._handleAddPermission();
await flush();
- newSection.shadowRoot
- .querySelector('gr-permission')._handleAddRuleItem(
- {detail: {value: 'Maintainers'}});
+ queryAndAssert<GrPermission>(
+ newSection,
+ 'gr-permission'
+ )._handleAddRuleItem({
+ detail: {value: 'Maintainers'},
+ } as AutocompleteCommitEvent);
// Modify a the reference from the default value.
- element._local['refs/for/*'].updatedId = 'refs/for/new';
+ element._local!['refs/for/*'].updatedId = 'refs/for/new';
await flush();
expectedInput = {
@@ -1079,8 +1216,9 @@
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Modify newly added rule inside new ref.
- element._local['refs/for/*'].permissions['label-Code-Review'].
- rules['Maintainers'].modified = true;
+ element._local!['refs/for/*'].permissions['label-Code-Review'].rules[
+ 'Maintainers'
+ ].modified = true;
await flush();
expectedInput = {
add: {
@@ -1115,15 +1253,17 @@
// Add a second new section.
MockInteractions.tap(element.$.addReferenceBtn);
await flush();
- newSection = dom(element.root)
- .querySelectorAll('gr-access-section')[2];
+ newSection = queryAll<GrAccessSection>(element, 'gr-access-section')[2];
newSection._handleAddPermission();
await flush();
- newSection.shadowRoot
- .querySelector('gr-permission')._handleAddRuleItem(
- {detail: {value: 'Maintainers'}});
+ queryAndAssert<GrPermission>(
+ newSection,
+ 'gr-permission'
+ )._handleAddRuleItem({
+ detail: {value: 'Maintainers'},
+ } as AutocompleteCommitEvent);
// Modify a the reference from the default value.
- element._local['refs/for/**'].updatedId = 'refs/for/new2';
+ element._local!['refs/for/**'].updatedId = 'refs/for/new2';
await flush();
expectedInput = {
add: {
@@ -1178,16 +1318,16 @@
// Unsaved changes are discarded when editing is cancelled.
MockInteractions.tap(element.$.editBtn);
await flush();
- assert.equal(element._sections.length, 1);
- assert.equal(Object.keys(element._local).length, 1);
+ assert.equal(element._sections!.length, 1);
+ assert.equal(Object.keys(element._local!).length, 1);
MockInteractions.tap(element.$.addReferenceBtn);
await flush();
- assert.equal(element._sections.length, 2);
- assert.equal(Object.keys(element._local).length, 2);
+ assert.equal(element._sections!.length, 2);
+ assert.equal(Object.keys(element._local!).length, 2);
MockInteractions.tap(element.$.editBtn);
await flush();
- assert.equal(element._sections.length, 1);
- assert.equal(Object.keys(element._local).length, 1);
+ assert.equal(element._sections!.length, 1);
+ assert.equal(Object.keys(element._local!).length, 1);
});
test('_handleSave', async () => {
@@ -1216,24 +1356,25 @@
},
};
stubRestApi('getRepoAccessRights').returns(
- Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
- sinon.stub(GerritNav, 'navigateToChange');
- let resolver;
- const saveStub = stubRestApi(
- 'setRepoAccessRights')
- .returns(new Promise(r => resolver = r));
+ Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+ );
+ const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+ let resolver: (value: Response | PromiseLike<Response>) => void;
+ const saveStub = stubRestApi('setRepoAccessRights').returns(
+ new Promise(r => (resolver = r))
+ );
- element.repo = 'test-repo';
+ element.repo = 'test-repo' as RepoName;
sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
element._modified = true;
MockInteractions.tap(element.$.saveBtn);
await flush();
assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
- resolver({_number: 1});
+ resolver!({status: 200} as Response);
await flush();
assert.isTrue(saveStub.called);
- assert.isTrue(GerritNav.navigateToChange.notCalled);
+ assert.isTrue(navigateToChangeStub.notCalled);
});
test('_handleSaveForReview', async () => {
@@ -1262,26 +1403,27 @@
},
};
stubRestApi('getRepoAccessRights').returns(
- Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
- sinon.stub(GerritNav, 'navigateToChange');
- let resolver;
+ Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+ );
+ const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+ let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
const saveForReviewStub = stubRestApi(
- 'setRepoAccessRightsForReview')
- .returns(new Promise(r => resolver = r));
+ 'setRepoAccessRightsForReview'
+ ).returns(new Promise(r => (resolver = r)));
- element.repo = 'test-repo';
+ element.repo = 'test-repo' as RepoName;
sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
element._modified = true;
MockInteractions.tap(element.$.saveReviewBtn);
await flush();
assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
- resolver({_number: 1});
+ resolver!(createChange());
await flush();
assert.isTrue(saveForReviewStub.called);
- assert.isTrue(GerritNav.navigateToChange
- .lastCall.calledWithExactly({_number: 1}));
+ assert.isTrue(
+ navigateToChangeStub.lastCall.calledWithExactly(createChange())
+ );
});
});
});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index f309171..1076990 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -50,7 +50,6 @@
import {deepClone} from '../../../utils/deep-util';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators';
-import {preferences$} from '../../../services/user/user-model';
import {subscribe} from '../../lit/subscription-controller';
const STATES = {
@@ -122,11 +121,13 @@
@state() private pluginConfigChanged = false;
+ private readonly userModel = getAppContext().userModel;
+
private readonly restApiService = getAppContext().restApiService;
constructor() {
super();
- subscribe(this, preferences$, prefs => {
+ subscribe(this, this.userModel.preferences$, prefs => {
if (prefs?.download_scheme) {
// Note (issue 5180): normalize the download scheme with lower-case.
this.selectedScheme = prefs.download_scheme.toLowerCase();
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 172f807..f811ee2 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -71,7 +71,7 @@
];
interface Rule {
- value: RuleValue;
+ value?: RuleValue;
}
interface RuleValue {
@@ -158,17 +158,17 @@
// Observer _handleValueChange is called after the ready()
// method finishes. Original values must be set later to
// avoid set .modified flag to true
- this._setOriginalRuleValues(this.rule.value);
+ this._setOriginalRuleValues(this.rule?.value);
}
}
- _setupValues(rule: Rule) {
- if (!rule.value) {
+ _setupValues(rule?: Rule) {
+ if (!rule?.value) {
this._setDefaultRuleValues();
}
}
- _computeForce(permission: AccessPermissionId, action: string) {
+ _computeForce(permission: AccessPermissionId, action?: string) {
if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
return true;
}
@@ -176,7 +176,7 @@
return AccessPermissionId.EDIT_TOPIC_NAME === permission;
}
- _computeForceClass(permission: AccessPermissionId, action: string) {
+ _computeForceClass(permission: AccessPermissionId, action?: string) {
return this._computeForce(permission, action) ? 'force' : '';
}
@@ -213,7 +213,7 @@
return classList.join(' ');
}
- _computeForceOptions(permission: string, action: string) {
+ _computeForceOptions(permission: string, action?: string) {
if (permission === AccessPermissionId.PUSH) {
if (action === Action.ALLOW) {
return ForcePushOptions.ALLOW;
@@ -259,7 +259,7 @@
}
_handleRemoveRule() {
- if (!this.rule) return;
+ if (!this.rule?.value) return;
if (this.rule.value.added) {
fireEvent(this, 'added-rule-removed');
}
@@ -269,13 +269,13 @@
}
_handleUndoRemove() {
- if (!this.rule) return;
+ if (!this.rule?.value) return;
this._deleted = false;
delete this.rule.value.deleted;
}
_handleUndoChange() {
- if (!this.rule) return;
+ if (!this.rule?.value) return;
// gr-permission will take care of removing rules that were added but
// unsaved. We need to keep the added bit for the filter.
if (this.rule.value.added) {
@@ -289,7 +289,7 @@
@observe('rule.value.*')
_handleValueChange() {
- if (!this._originalRuleValues || !this.rule) {
+ if (!this._originalRuleValues || !this.rule?.value) {
return;
}
this.rule.value.modified = true;
@@ -297,7 +297,8 @@
fireEvent(this, 'access-modified');
}
- _setOriginalRuleValues(value: RuleValue) {
+ _setOriginalRuleValues(value?: RuleValue) {
+ if (value === undefined) return;
this._originalRuleValues = {...value};
}
}
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
deleted file mode 100644
index f3df132..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ /dev/null
@@ -1,586 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.js';
-import './gr-rule-editor.js';
-
-const basicFixture = fixtureFromElement('gr-rule-editor');
-
-suite('gr-rule-editor tests', () => {
- let element;
-
- setup(() => {
- element = basicFixture.instantiate();
- });
-
- suite('unit tests', () => {
- test('_computeForce, _computeForceClass, and _computeForceOptions',
- () => {
- const ForcePushOptions = {
- ALLOW: [
- {name: 'Allow pushing (but not force pushing)', value: false},
- {name: 'Allow pushing with or without force', value: true},
- ],
- BLOCK: [
- {name: 'Block pushing with or without force', value: false},
- {name: 'Block force pushing', value: true},
- ],
- };
-
- const FORCE_EDIT_OPTIONS = [
- {
- name: 'No Force Edit',
- value: false,
- },
- {
- name: 'Force Edit',
- value: true,
- },
- ];
- let permission = 'push';
- let action = 'ALLOW';
- assert.isTrue(element._computeForce(permission, action));
- assert.equal(element._computeForceClass(permission, action),
- 'force');
- assert.deepEqual(element._computeForceOptions(permission, action),
- ForcePushOptions.ALLOW);
-
- action = 'BLOCK';
- assert.isTrue(element._computeForce(permission, action));
- assert.equal(element._computeForceClass(permission, action),
- 'force');
- assert.deepEqual(element._computeForceOptions(permission, action),
- ForcePushOptions.BLOCK);
-
- action = 'DENY';
- assert.isFalse(element._computeForce(permission, action));
- assert.equal(element._computeForceClass(permission, action), '');
- assert.equal(
- element._computeForceOptions(permission, action).length, 0);
-
- permission = 'editTopicName';
- assert.isTrue(element._computeForce(permission));
- assert.equal(element._computeForceClass(permission), 'force');
- assert.deepEqual(element._computeForceOptions(permission),
- FORCE_EDIT_OPTIONS);
- permission = 'submit';
- assert.isFalse(element._computeForce(permission));
- assert.equal(element._computeForceClass(permission), '');
- assert.deepEqual(element._computeForceOptions(permission), []);
- });
-
- test('_computeSectionClass', () => {
- let deleted = true;
- let editing = false;
- assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
- deleted = false;
- assert.equal(element._computeSectionClass(editing, deleted), '');
-
- editing = true;
- assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
- deleted = true;
- assert.equal(element._computeSectionClass(editing, deleted),
- 'editing deleted');
- });
-
- test('_getDefaultRuleValues', () => {
- let permission = 'priority';
- let label;
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'BATCH'});
- permission = 'label-Code-Review';
- label = {values: [
- {value: -2, text: 'This shall not be merged'},
- {value: -1, text: 'I would prefer this is not merged as is'},
- {value: -0, text: 'No score'},
- {value: 1, text: 'Looks good to me, but someone else must approve'},
- {value: 2, text: 'Looks good to me, approved'},
- ]};
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'ALLOW', max: 2, min: -2});
- permission = 'push';
- label = undefined;
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'ALLOW', force: false});
- permission = 'submit';
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'ALLOW'});
- });
-
- test('_setDefaultRuleValues', () => {
- element.rule = {id: 123};
- const defaultValue = {action: 'ALLOW'};
- sinon.stub(element, '_getDefaultRuleValues').returns(defaultValue);
- element._setDefaultRuleValues();
- assert.isTrue(element._getDefaultRuleValues.called);
- assert.equal(element.rule.value, defaultValue);
- });
-
- test('_computeOptions', () => {
- const PRIORITY_OPTIONS = [
- 'BATCH',
- 'INTERACTIVE',
- ];
- const DROPDOWN_OPTIONS = [
- 'ALLOW',
- 'DENY',
- 'BLOCK',
- ];
- let permission = 'priority';
- assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
- permission = 'submit';
- assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
- });
-
- test('_handleValueChange', () => {
- const modifiedHandler = sinon.stub();
- element.rule = {value: {}};
- element.addEventListener('access-modified', modifiedHandler);
- element._handleValueChange();
- assert.isNotOk(element.rule.value.modified);
- element._originalRuleValues = {};
- element._handleValueChange();
- assert.isTrue(element.rule.value.modified);
- assert.isTrue(modifiedHandler.called);
- });
-
- test('_handleAccessSaved', () => {
- const originalValue = {action: 'DENY'};
- const newValue = {action: 'ALLOW'};
- element._originalRuleValues = originalValue;
- element.rule = {value: newValue};
- element._handleAccessSaved();
- assert.deepEqual(element._originalRuleValues, newValue);
- });
-
- test('_setOriginalRuleValues', () => {
- const value = {
- action: 'ALLOW',
- force: false,
- };
- element._setOriginalRuleValues(value);
- assert.deepEqual(element._originalRuleValues, value);
- });
- });
-
- suite('already existing generic rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'submit';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: false,
- },
- };
- element.section = 'refs/*';
-
- // Typically called on ready since elements will have properties defined
- // by the parent element.
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.isNotOk(element.root.querySelector('#labelMin'));
- assert.isNotOk(element.root.querySelector('#labelMax'));
- assert.isFalse(element.$.force.classList.contains('force'));
- });
-
- test('modify and cancel restores original values', () => {
- element.editing = true;
- assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = 'DENY';
- assert.isTrue(element.rule.value.modified);
- element.editing = false;
- assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- assert.equal(element.$.action.bindValue, 'ALLOW');
- assert.isNotOk(element.rule.value.modified);
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = 'DENY';
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('all selects are disabled when not in edit mode', () => {
- const selects = element.root.querySelectorAll('select');
- for (const select of selects) {
- assert.isTrue(select.disabled);
- }
- element.editing = true;
- for (const select of selects) {
- assert.isFalse(select.disabled);
- }
- });
-
- test('remove rule and undo remove', () => {
- element.editing = true;
- element.rule = {id: 123, value: {action: 'ALLOW'}};
- assert.isFalse(
- element.$.deletedContainer.classList.contains('deleted'));
- MockInteractions.tap(element.$.removeBtn);
- assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
- assert.isTrue(element._deleted);
- assert.isTrue(element.rule.value.deleted);
-
- MockInteractions.tap(element.$.undoRemoveBtn);
- assert.isFalse(element._deleted);
- assert.isNotOk(element.rule.value.deleted);
- });
-
- test('remove rule and cancel', () => {
- element.editing = true;
- assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.equal(getComputedStyle(element.$.deletedContainer).display,
- 'none');
-
- element.rule = {id: 123, value: {action: 'ALLOW'}};
- MockInteractions.tap(element.$.removeBtn);
- assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
- 'none');
- assert.isTrue(element._deleted);
- assert.isTrue(element.rule.value.deleted);
-
- element.editing = false;
- assert.isFalse(element._deleted);
- assert.isNotOk(element.rule.value.deleted);
- assert.isNotOk(element.rule.value.modified);
-
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.equal(getComputedStyle(element.$.deletedContainer).display,
- 'none');
- });
-
- test('_computeGroupPath', () => {
- const group = '123';
- assert.equal(element._computeGroupPath(group),
- `/admin/groups/123`);
- });
- });
-
- suite('new edit rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'editTopicName';
- element.rule = {
- id: '123',
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.rule.value.added = true;
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- // Since the element does not already have default values, they should
- // be set. The original values should be set to those too.
- assert.isNotOk(element.rule.value.modified);
- const expectedRuleValue = {
- action: 'ALLOW',
- force: false,
- added: true,
- };
- assert.deepEqual(element.rule.value, expectedRuleValue);
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, expectedRuleValue.action);
- assert.equal(element.$.force.bindValue, expectedRuleValue.action);
- });
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.force.bindValue = true;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('remove value', () => {
- element.editing = true;
- const removeStub = sinon.stub();
- element.addEventListener('added-rule-removed', removeStub);
- MockInteractions.tap(element.$.removeBtn);
- flush();
- assert.isTrue(removeStub.called);
- });
- });
-
- suite('already existing rule with labels', () => {
- setup(async () => {
- element.label = {values: [
- {value: -2, text: 'This shall not be merged'},
- {value: -1, text: 'I would prefer this is not merged as is'},
- {value: -0, text: 'No score'},
- {value: 1, text: 'Looks good to me, but someone else must approve'},
- {value: 2, text: 'Looks good to me, approved'},
- ]};
- element.group = 'Group Name';
- element.permission = 'label-Code-Review';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: false,
- max: 2,
- min: -2,
- },
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.equal(
- element.root.querySelector('#labelMin').bindValue,
- element.rule.value.min);
- assert.equal(
- element.root.querySelector('#labelMax').bindValue,
- element.rule.value.max);
- assert.isFalse(element.$.force.classList.contains('force'));
- });
-
- test('modify value', () => {
- const removeStub = sinon.stub();
- element.addEventListener('added-rule-removed', removeStub);
- assert.isNotOk(element.rule.value.modified);
- element.root.querySelector('#labelMin').bindValue = 1;
- flush();
- assert.isTrue(element.rule.value.modified);
- assert.isFalse(removeStub.called);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('new rule with labels', () => {
- setup(async () => {
- sinon.spy(element, '_setDefaultRuleValues');
- element.label = {values: [
- {value: -2, text: 'This shall not be merged'},
- {value: -1, text: 'I would prefer this is not merged as is'},
- {value: -0, text: 'No score'},
- {value: 1, text: 'Looks good to me, but someone else must approve'},
- {value: 2, text: 'Looks good to me, approved'},
- ]};
- element.group = 'Group Name';
- element.permission = 'label-Code-Review';
- element.rule = {
- id: '123',
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.rule.value.added = true;
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- // Since the element does not already have default values, they should
- // be set. The original values should be set to those too.
- assert.isNotOk(element.rule.value.modified);
- assert.isTrue(element._setDefaultRuleValues.called);
-
- const expectedRuleValue = {
- max: element.label.values[element.label.values.length - 1].value,
- min: element.label.values[0].value,
- action: 'ALLOW',
- added: true,
- };
- assert.deepEqual(element.rule.value, expectedRuleValue);
- test('values are set correctly', () => {
- assert.equal(
- element.$.action.bindValue,
- expectedRuleValue.action);
- assert.equal(
- element.root.querySelector('#labelMin').bindValue,
- expectedRuleValue.min);
- assert.equal(
- element.root.querySelector('#labelMax').bindValue,
- expectedRuleValue.max);
- });
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.root.querySelector('#labelMin').bindValue = 1;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('already existing push rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'push';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: true,
- },
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.isTrue(element.$.force.classList.contains('force'));
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.equal(
- element.root.querySelector('#force').bindValue,
- element.rule.value.force);
- assert.isNotOk(element.root.querySelector('#labelMin'));
- assert.isNotOk(element.root.querySelector('#labelMax'));
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = false;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('new push rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'push';
- element.rule = {
- id: '123',
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.rule.value.added = true;
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- // Since the element does not already have default values, they should
- // be set. The original values should be set to those too.
- assert.isNotOk(element.rule.value.modified);
- const expectedRuleValue = {
- action: 'ALLOW',
- force: false,
- added: true,
- };
- assert.deepEqual(element.rule.value, expectedRuleValue);
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, expectedRuleValue.action);
- assert.equal(element.$.force.bindValue, expectedRuleValue.action);
- });
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.force.bindValue = true;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('already existing edit rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'editTopicName';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: true,
- },
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.isTrue(element.$.force.classList.contains('force'));
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.equal(
- element.root.querySelector('#force').bindValue,
- element.rule.value.force);
- assert.isNotOk(element.root.querySelector('#labelMin'));
- assert.isNotOk(element.root.querySelector('#labelMax'));
- });
-
- test('modify value', async () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = false;
- await flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
new file mode 100644
index 0000000..1afd123
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -0,0 +1,685 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 './gr-rule-editor';
+import {GrRuleEditor} from './gr-rule-editor';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-rule-editor');
+
+suite('gr-rule-editor tests', () => {
+ let element: GrRuleEditor;
+
+ setup(() => {
+ element = basicFixture.instantiate();
+ });
+
+ suite('unit tests', () => {
+ test('_computeForce, _computeForceClass, and _computeForceOptions', () => {
+ const ForcePushOptions = {
+ ALLOW: [
+ {name: 'Allow pushing (but not force pushing)', value: false},
+ {name: 'Allow pushing with or without force', value: true},
+ ],
+ BLOCK: [
+ {name: 'Block pushing with or without force', value: false},
+ {name: 'Block force pushing', value: true},
+ ],
+ };
+
+ const FORCE_EDIT_OPTIONS = [
+ {
+ name: 'No Force Edit',
+ value: false,
+ },
+ {
+ name: 'Force Edit',
+ value: true,
+ },
+ ];
+ let permission = 'push' as AccessPermissionId;
+ let action = 'ALLOW';
+ assert.isTrue(element._computeForce(permission, action));
+ assert.equal(element._computeForceClass(permission, action), 'force');
+ assert.deepEqual(
+ element._computeForceOptions(permission, action),
+ ForcePushOptions.ALLOW
+ );
+
+ action = 'BLOCK';
+ assert.isTrue(element._computeForce(permission, action));
+ assert.equal(element._computeForceClass(permission, action), 'force');
+ assert.deepEqual(
+ element._computeForceOptions(permission, action),
+ ForcePushOptions.BLOCK
+ );
+
+ action = 'DENY';
+ assert.isFalse(element._computeForce(permission, action));
+ assert.equal(element._computeForceClass(permission, action), '');
+ assert.equal(element._computeForceOptions(permission, action).length, 0);
+
+ permission = 'editTopicName' as AccessPermissionId;
+ assert.isTrue(element._computeForce(permission));
+ assert.equal(element._computeForceClass(permission), 'force');
+ assert.deepEqual(
+ element._computeForceOptions(permission),
+ FORCE_EDIT_OPTIONS
+ );
+ permission = 'submit' as AccessPermissionId;
+ assert.isFalse(element._computeForce(permission));
+ assert.equal(element._computeForceClass(permission), '');
+ assert.deepEqual(element._computeForceOptions(permission), []);
+ });
+
+ test('_computeSectionClass', () => {
+ let deleted = true;
+ let editing = false;
+ assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+ deleted = false;
+ assert.equal(element._computeSectionClass(editing, deleted), '');
+
+ editing = true;
+ assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+ deleted = true;
+ assert.equal(
+ element._computeSectionClass(editing, deleted),
+ 'editing deleted'
+ );
+ });
+
+ test('_getDefaultRuleValues', () => {
+ let permission = 'priority' as AccessPermissionId;
+ let label;
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'BATCH',
+ });
+ permission = 'label-Code-Review' as AccessPermissionId;
+ label = {
+ values: [
+ {value: -2, text: 'This shall not be merged'},
+ {value: -1, text: 'I would prefer this is not merged as is'},
+ {value: -0, text: 'No score'},
+ {value: 1, text: 'Looks good to me, but someone else must approve'},
+ {value: 2, text: 'Looks good to me, approved'},
+ ],
+ };
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'ALLOW',
+ max: 2,
+ min: -2,
+ });
+ permission = 'push' as AccessPermissionId;
+ label = undefined;
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'ALLOW',
+ force: false,
+ });
+ permission = 'submit' as AccessPermissionId;
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'ALLOW',
+ });
+ });
+
+ test('_setDefaultRuleValues', async () => {
+ element.rule = {value: {}};
+ const defaultValue = {action: 'ALLOW'};
+ const getDefaultRuleValuesStub = sinon
+ .stub(element, '_getDefaultRuleValues')
+ .returns(defaultValue);
+ element._setDefaultRuleValues();
+ assert.isTrue(getDefaultRuleValuesStub.called);
+ assert.equal(element.rule!.value, defaultValue);
+ });
+
+ test('_computeOptions', () => {
+ const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+ const DROPDOWN_OPTIONS = ['ALLOW', 'DENY', 'BLOCK'];
+ let permission = 'priority';
+ assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+ permission = 'submit';
+ assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+ });
+
+ test('_handleValueChange', () => {
+ const modifiedHandler = sinon.stub();
+ element.rule = {value: {}};
+ element.addEventListener('access-modified', modifiedHandler);
+ element._handleValueChange();
+ assert.isNotOk(element.rule!.value!.modified);
+ element._originalRuleValues = {};
+ element._handleValueChange();
+ assert.isTrue(element.rule!.value!.modified);
+ assert.isTrue(modifiedHandler.called);
+ });
+
+ test('_handleAccessSaved', () => {
+ const originalValue = {action: 'DENY'};
+ const newValue = {action: 'ALLOW'};
+ element._originalRuleValues = originalValue;
+ element.rule = {value: newValue};
+ element._handleAccessSaved();
+ assert.deepEqual(element._originalRuleValues, newValue);
+ });
+
+ test('_setOriginalRuleValues', () => {
+ const value = {
+ action: 'ALLOW',
+ force: false,
+ };
+ element._setOriginalRuleValues(value);
+ assert.deepEqual(element._originalRuleValues, value);
+ });
+ });
+
+ suite('already existing generic rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'submit' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: false,
+ },
+ };
+ element.section = 'refs/*';
+
+ // Typically called on ready since elements will have properties defined
+ // by the parent element.
+ element._setupValues(element.rule);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+ assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+ assert.isFalse(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ });
+
+ test('modify and cancel restores original values', () => {
+ element.editing = true;
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = 'DENY';
+ assert.isTrue(element.rule!.value!.modified);
+ element.editing = false;
+ assert.equal(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ 'ALLOW'
+ );
+ assert.isNotOk(element.rule!.value!.modified);
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = 'DENY';
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('all selects are disabled when not in edit mode', () => {
+ const selects = queryAll<HTMLSelectElement>(element, 'select');
+ for (const select of selects) {
+ assert.isTrue(select.disabled);
+ }
+ element.editing = true;
+ for (const select of selects) {
+ assert.isFalse(select.disabled);
+ }
+ });
+
+ test('remove rule and undo remove', () => {
+ element.editing = true;
+ element.rule = {value: {action: 'ALLOW'}};
+ assert.isFalse(
+ queryAndAssert<HTMLDivElement>(
+ element,
+ '#deletedContainer'
+ ).classList.contains('deleted')
+ );
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+ assert.isTrue(
+ queryAndAssert<HTMLDivElement>(
+ element,
+ '#deletedContainer'
+ ).classList.contains('deleted')
+ );
+ assert.isTrue(element._deleted);
+ assert.isTrue(element.rule!.value!.deleted);
+
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+ assert.isFalse(element._deleted);
+ assert.isNotOk(element.rule!.value!.deleted);
+ });
+
+ test('remove rule and cancel', () => {
+ element.editing = true;
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.equal(
+ getComputedStyle(
+ queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+ ).display,
+ 'none'
+ );
+
+ element.rule = {value: {action: 'ALLOW'}};
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.notEqual(
+ getComputedStyle(
+ queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+ ).display,
+ 'none'
+ );
+ assert.isTrue(element._deleted);
+ assert.isTrue(element.rule!.value!.deleted);
+
+ element.editing = false;
+ assert.isFalse(element._deleted);
+ assert.isNotOk(element.rule!.value!.deleted);
+ assert.isNotOk(element.rule!.value!.modified);
+
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ assert.equal(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.equal(
+ getComputedStyle(
+ queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+ ).display,
+ 'none'
+ );
+ });
+
+ test('_computeGroupPath', () => {
+ const group = '123';
+ assert.equal(element._computeGroupPath(group), '/admin/groups/123');
+ });
+ });
+
+ suite('new edit rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'editTopicName' as AccessPermissionId;
+ element.rule = {};
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.rule!.value!.added = true;
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ // Since the element does not already have default values, they should
+ // be set. The original values should be set to those too.
+ assert.isNotOk(element.rule!.value!.modified);
+ const expectedRuleValue = {
+ action: 'ALLOW',
+ force: false,
+ added: true,
+ };
+ assert.deepEqual(element.rule!.value, expectedRuleValue);
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ expectedRuleValue.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ expectedRuleValue.action
+ );
+ });
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+ forceBindValue.bindValue = true;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('remove value', () => {
+ element.editing = true;
+ const removeStub = sinon.stub();
+ element.addEventListener('added-rule-removed', removeStub);
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+ flush();
+ assert.isTrue(removeStub.called);
+ });
+ });
+
+ suite('already existing rule with labels', () => {
+ setup(async () => {
+ element.label = {
+ values: [
+ {value: -2, text: 'This shall not be merged'},
+ {value: -1, text: 'I would prefer this is not merged as is'},
+ {value: -0, text: 'No score'},
+ {value: 1, text: 'Looks good to me, but someone else must approve'},
+ {value: 2, text: 'Looks good to me, approved'},
+ ],
+ };
+ element.groupName = 'Group Name';
+ element.permission = 'label-Code-Review' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: false,
+ max: 2,
+ min: -2,
+ },
+ };
+ element.section = 'refs/*';
+ element._setupValues(element.rule);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+ element.rule!.value!.min
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+ element.rule!.value!.max
+ );
+ assert.isFalse(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ });
+
+ test('modify value', () => {
+ const removeStub = sinon.stub();
+ element.addEventListener('added-rule-removed', removeStub);
+ assert.isNotOk(element.rule!.value!.modified);
+ const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+ labelMinBindValue.bindValue = 1;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+ assert.isFalse(removeStub.called);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('new rule with labels', () => {
+ let setDefaultRuleValuesSpy: sinon.SinonSpy;
+
+ setup(async () => {
+ setDefaultRuleValuesSpy = sinon.spy(element, '_setDefaultRuleValues');
+ element.label = {
+ values: [
+ {value: -2, text: 'This shall not be merged'},
+ {value: -1, text: 'I would prefer this is not merged as is'},
+ {value: -0, text: 'No score'},
+ {value: 1, text: 'Looks good to me, but someone else must approve'},
+ {value: 2, text: 'Looks good to me, approved'},
+ ],
+ };
+ element.groupName = 'Group Name';
+ element.permission = 'label-Code-Review' as AccessPermissionId;
+ element.rule = {};
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.rule!.value!.added = true;
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ // Since the element does not already have default values, they should
+ // be set. The original values should be set to those too.
+ assert.isNotOk(element.rule!.value!.modified);
+ assert.isTrue(setDefaultRuleValuesSpy.called);
+
+ const expectedRuleValue = {
+ max: element.label!.values![element.label!.values.length - 1].value,
+ min: element.label!.values![0].value,
+ action: 'ALLOW',
+ added: true,
+ };
+ assert.deepEqual(element.rule!.value, expectedRuleValue);
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ expectedRuleValue.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+ expectedRuleValue.min
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+ expectedRuleValue.max
+ );
+ });
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+ labelMinBindValue.bindValue = 1;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('already existing push rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'push' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: true,
+ },
+ };
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.isTrue(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ element.rule!.value!.force
+ );
+ assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+ assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = false;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('new push rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'push' as AccessPermissionId;
+ element.rule = {};
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.rule!.value!.added = true;
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ // Since the element does not already have default values, they should
+ // be set. The original values should be set to those too.
+ assert.isNotOk(element.rule!.value!.modified);
+ const expectedRuleValue = {
+ action: 'ALLOW',
+ force: false,
+ added: true,
+ };
+ assert.deepEqual(element.rule!.value, expectedRuleValue);
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ expectedRuleValue.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ expectedRuleValue.action
+ );
+ });
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+ forceBindValue.bindValue = true;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('already existing edit rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'editTopicName' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: true,
+ },
+ };
+ element.section = 'refs/*';
+ element._setupValues(element.rule);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.isTrue(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ element.rule!.value!.force
+ );
+ assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+ assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+ });
+
+ test('modify value', async () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = false;
+ await flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index a098d03..d4b0328 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -44,8 +44,12 @@
} from '../../../types/common';
import {assertNever, hasOwnProperty} from '../../../utils/common-util';
import {pluralize} from '../../../utils/string-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {getRequirements, iconForStatus} from '../../../utils/label-util';
+import {
+ getRequirements,
+ iconForStatus,
+ showNewSubmitRequirements,
+ StandardLabels,
+} from '../../../utils/label-util';
import {SubmitRequirementStatus} from '../../../api/rest-api';
import {changeListStyles} from '../../../styles/gr-change-list-styles';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -109,17 +113,12 @@
@state() private dynamicCellEndpoints?: string[];
- @state() private isSubmitRequirementsUiEnabled = false;
-
reporting: ReportingService = getAppContext().reportingService;
private readonly flagsService = getAppContext().flagsService;
override connectedCallback() {
super.connectedCallback();
- this.isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
- KnownExperimentId.SUBMIT_REQUIREMENTS_UI
- );
getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
@@ -200,6 +199,9 @@
.subject:hover .content {
text-decoration: underline;
}
+ .requirement {
+ text-align: left;
+ }
.u-monospace {
font-family: var(--monospace-font-family);
font-size: var(--font-size-mono);
@@ -226,6 +228,9 @@
.cell.label iron-icon {
vertical-align: top;
}
+ .cell.label > .commentIcon {
+ color: var(--deemphasized-text-color);
+ }
@media only screen and (max-width: 50em) {
:host {
display: flex;
@@ -512,6 +517,7 @@
class="${this.computeLabelClass(labelName)}"
>
${this.renderChangeHasLabelIcon(labelName)}
+ ${this.renderCommentsInfoWithLabel(labelName)}
</td>
`;
}
@@ -525,6 +531,16 @@
`;
}
+ private renderCommentsInfoWithLabel(labelName: string) {
+ if (!showNewSubmitRequirements(this.flagsService, this.change)) return;
+ if (labelName !== StandardLabels.CODE_REVIEW) return;
+ if (!this.change?.unresolved_comment_count) return;
+ return html`<iron-icon
+ icon="gr-icons:comment"
+ class="commentIcon"
+ ></iron-icon>`;
+ }
+
private renderChangePluginEndpoint(pluginEndpointName: string) {
return html`
<td class="cell endpoint">
@@ -572,12 +588,13 @@
// private but used in test
computeLabelClass(labelName: string) {
const classes = ['cell', 'label'];
- if (this.isSubmitRequirementsUiEnabled) {
+ if (showNewSubmitRequirements(this.flagsService, this.change)) {
const requirements = getRequirements(this.change).filter(
sr => sr.name === labelName
);
if (requirements.length === 1) {
const status = requirements[0].status;
+ classes.push('requirement');
switch (status) {
case SubmitRequirementStatus.SATISFIED:
classes.push('u-green');
@@ -622,7 +639,7 @@
// private but used in test
computeLabelIcon(labelName: string): string {
- if (this.isSubmitRequirementsUiEnabled) {
+ if (showNewSubmitRequirements(this.flagsService, this.change)) {
const requirements = getRequirements(this.change).filter(
sr => sr.name === labelName
);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 4b39499..7cebc11 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -15,10 +15,16 @@
* limitations under the License.
*/
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
import '../../../test/common-test-setup-karma';
import {
createAccountWithId,
createChange,
+ createSubmitRequirementExpressionInfo,
+ createSubmitRequirementResultInfo,
} from '../../../test/test-data-generators';
import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
import {
@@ -28,6 +34,7 @@
RepoName,
TopicName,
} from '../../../types/common';
+import {StandardLabels} from '../../../utils/label-util';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {columnNames} from '../gr-change-list/gr-change-list';
import './gr-change-list-item';
@@ -463,4 +470,34 @@
assert.equal(element.computeRepoDisplay(), 'a/test/repo');
assert.equal(element.computeTruncatedRepoDisplay(), '…/test/repo');
});
+
+ test('renders requirement with new submit requirements', async () => {
+ sinon.stub(getAppContext().flagsService, 'isEnabled').returns(true);
+ const submitRequirement: SubmitRequirementResultInfo = {
+ ...createSubmitRequirementResultInfo(),
+ name: StandardLabels.CODE_REVIEW,
+ submittability_expression_result: {
+ ...createSubmitRequirementExpressionInfo(),
+ expression: 'label:Verified=MAX -label:Verified=MIN',
+ },
+ };
+ const change: ChangeInfo = {
+ ...createChange(),
+ submit_requirements: [submitRequirement],
+ unresolved_comment_count: 1,
+ };
+ const element = await fixture<GrChangeListItem>(
+ html`<gr-change-list-item
+ .change=${change}
+ .labelNames=${[StandardLabels.CODE_REVIEW]}
+ ></gr-change-list-item>`
+ );
+
+ const requirement = queryAndAssert(element, '.requirement');
+ expect(requirement).dom.to.equal(`<iron-icon icon="gr-icons:check">
+ </iron-icon>
+ <iron-icon class="commentIcon" icon="gr-icons:comment">
+ </iron-icon>
+ `);
+ });
});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 5ae5271..7d618f5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -51,9 +51,11 @@
import {fireEvent, fireReload} from '../../../utils/event-util';
import {ScrollMode} from '../../../constants/constants';
import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {PRIORITY_REQUIREMENTS_ORDER} from '../../../utils/label-util';
-import {addShortcut, Key} from '../../../utils/dom-util';
+import {
+ PRIORITY_REQUIREMENTS_ORDER,
+ showNewSubmitRequirements,
+} from '../../../utils/label-util';
+import {addGlobalShortcut, Key} from '../../../utils/dom-util';
const NUMBER_FIXED_COLUMNS = 3;
const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -176,7 +178,7 @@
super();
this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
this.cursor.focusOnMove = true;
- addShortcut(this, {key: Key.ENTER}, () => this.openChange());
+ addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
}
override ready() {
@@ -205,20 +207,22 @@
return column.toLowerCase();
}
- @observe('account', 'preferences', '_config')
+ @observe('account', 'preferences', '_config', 'sections')
_computePreferences(
account?: AccountInfo,
preferences?: PreferencesInput,
- config?: ServerInfo
+ config?: ServerInfo,
+ sections?: ChangeListSection[]
) {
if (!config) {
return;
}
+ const changes = (sections ?? []).map(section => section.results).flat();
this.changeTableColumns = columnNames;
this.showNumber = false;
this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
- this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+ this._isColumnEnabled(col, config, changes)
);
if (account && preferences) {
this.showNumber = !!(
@@ -229,11 +233,7 @@
column === 'Project' ? 'Repo' : column
);
this.visibleChangeTableColumns = prefColumns.filter(col =>
- this._isColumnEnabled(
- col,
- config,
- this.flagsService.enabledExperiments
- )
+ this._isColumnEnabled(col, config, changes)
);
}
}
@@ -242,12 +242,15 @@
/**
* Is the column disabled by a server config or experiment?
*/
- _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+ _isColumnEnabled(column: string, config: ServerInfo, changes?: ChangeInfo[]) {
if (!columnNames.includes(column)) return false;
if (!config || !config.change) return true;
- if (column === 'Comments') return experiments.includes('comments-column');
+ if (column === 'Comments')
+ return this.flagsService.isEnabled('comments-column');
if (column === 'Requirements')
- return experiments.includes(KnownExperimentId.SUBMIT_REQUIREMENTS_UI);
+ return (changes ?? []).every(change =>
+ showNewSubmitRequirements(this.flagsService, change)
+ );
return true;
}
@@ -302,9 +305,10 @@
labels = labels.concat(currentLabels.filter(nonExistingLabel));
}
}
+ const changes = sections.map(section => section.results).flat();
if (
- this.flagsService.enabledExperiments.includes(
- KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+ (changes ?? []).every(change =>
+ showNewSubmitRequirements(this.flagsService, change)
)
) {
labels = labels.filter(l => PRIORITY_REQUIREMENTS_ORDER.includes(l));
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index e635613..83808af 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -386,7 +386,7 @@
// Accessed in tests
readonly jsAPI = getAppContext().jsApiService;
- private readonly changeService = getAppContext().changeService;
+ private readonly changeModel = getAppContext().changeModel;
@property({type: Object})
change?: ChangeViewChangeInfo;
@@ -1716,7 +1716,7 @@
new Error('Properties change and changeNum must be set.')
);
}
- return this.changeService.fetchChangeUpdates(change).then(result => {
+ return this.changeModel.fetchChangeUpdates(change).then(result => {
if (!result.isLatest) {
this.dispatchEvent(
new CustomEvent<ShowAlertEventDetail>('show-alert', {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 416bd43..d0126fd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -87,7 +87,7 @@
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {getRevertCreatedChangeIds} from '../../../utils/message-util';
import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {showNewSubmitRequirements} from '../../../utils/label-util';
const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
@@ -213,9 +213,6 @@
@property({type: Object})
queryTopic?: AutocompleteQuery;
- @property({type: Boolean})
- _isSubmitRequirementsUiEnabled = false;
-
restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
@@ -225,9 +222,6 @@
override ready() {
super.ready();
this.queryTopic = (input: string) => this._getTopicSuggestions(input);
- this._isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
- KnownExperimentId.SUBMIT_REQUIREMENTS_UI
- );
}
@observe('change.labels')
@@ -713,13 +707,7 @@
}
_showNewSubmitRequirements(change?: ParsedChangeInfo) {
- if (!this._isSubmitRequirementsUiEnabled) return false;
- return (change?.submit_requirements ?? []).length > 0;
- }
-
- _showNewSubmitRequirementWarning(change?: ParsedChangeInfo) {
- if (!this._isSubmitRequirementsUiEnabled) return false;
- return (change?.submit_requirements ?? []).length === 0;
+ return showNewSubmitRequirements(this.flagsService, change);
}
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index 15631e4..46303a9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -113,10 +113,6 @@
--iron-icon-height: 18px;
--iron-icon-width: 18px;
}
- .submit-requirement-error {
- color: var(--deemphasized-text-color);
- padding-left: var(--metadata-horizontal-padding);
- }
</style>
<gr-external-style id="externalStyle" name="change-metadata">
<div class="metadata-header">
@@ -479,11 +475,6 @@
mutable="[[_mutable]]"
></gr-change-requirements>
</template>
- <template is="dom-if" if="[[_showNewSubmitRequirementWarning(change)]]">
- <div class="submit-requirement-error">
- New Submit Requirements don't work on this change.
- </div>
- </template>
</div>
<section
id="webLinks"
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index f072fa3..338c015 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -20,10 +20,6 @@
import './gr-change-metadata';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
- _testOnly_initGerritPluginApi,
- GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
import {GrChangeMetadata} from './gr-change-metadata';
import {
createServerInfo,
@@ -71,11 +67,9 @@
const basicFixture = fixtureFromElement('gr-change-metadata');
suite('gr-change-metadata tests', () => {
- let pluginApi: GerritInternal;
let element: GrChangeMetadata;
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
stubRestApi('getConfig').returns(
Promise.resolve({
@@ -897,7 +891,7 @@
}
let hookEl: MetadataGrEndpointDecorator;
let plugin: PluginApi;
- pluginApi.install(
+ window.Gerrit.install(
p => {
plugin = p;
plugin
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index 6991c03..8161592 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -154,7 +154,6 @@
mutable="[[mutable]]"
label="[[item.labelName]]"
label-info="[[item.labelInfo]]"
- showAlwaysOldUI
></gr-label-info>
</div>
</section>
@@ -205,7 +204,6 @@
mutable="[[mutable]]"
label="[[item.labelName]]"
label-info="[[item.labelInfo]]"
- showAlwaysOldUI
></gr-label-info>
</div>
</section>
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 0df3a3d..02d1beb 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
@@ -20,15 +20,9 @@
import {sharedStyles} from '../../../styles/shared-styles';
import {getAppContext} from '../../../services/app-context';
import {
- allRunsLatestPatchsetLatestAttempt$,
- aPluginHasRegistered$,
CheckResult,
CheckRun,
ErrorMessages,
- errorMessagesLatest$,
- loginCallbackLatest$,
- someProvidersAreLoadingFirstTime$,
- topLevelActionsLatest$,
} from '../../../services/checks/checks-model';
import {Action, Category, Link, RunStatus} from '../../../api/checks';
import {fireShowPrimaryTab} from '../../../utils/event-util';
@@ -65,11 +59,6 @@
import {modifierPressed} from '../../../utils/dom-util';
import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
import {fontStyles} from '../../../styles/gr-font-styles';
-import {account$} from '../../../services/user/user-model';
-import {
- changeComments$,
- threads$,
-} from '../../../services/comments/comments-model';
export enum SummaryChipStyles {
INFO = 'info',
@@ -412,23 +401,55 @@
private showAllChips = new Map<RunStatus | Category, boolean>();
- private checksService = getAppContext().checksService;
+ private commentsModel = getAppContext().commentsModel;
+
+ private userModel = getAppContext().userModel;
+
+ private checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
- subscribe(this, aPluginHasRegistered$, x => (this.showChecksSummary = x));
subscribe(
this,
- someProvidersAreLoadingFirstTime$,
+ this.checksModel.allRunsLatestPatchsetLatestAttempt$,
+ x => (this.runs = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.aPluginHasRegistered$,
+ x => (this.showChecksSummary = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.someProvidersAreLoadingFirstTime$,
x => (this.someProvidersAreLoading = x)
);
- subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
- subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
- subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
- subscribe(this, changeComments$, x => (this.changeComments = x));
- subscribe(this, threads$, x => (this.commentThreads = x));
- subscribe(this, account$, x => (this.selfAccount = x));
+ subscribe(
+ this,
+ this.checksModel.errorMessagesLatest$,
+ x => (this.errorMessages = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.loginCallbackLatest$,
+ x => (this.loginCallback = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.topLevelActionsLatest$,
+ x => (this.actions = x)
+ );
+ subscribe(
+ this,
+ this.commentsModel.changeComments$,
+ x => (this.changeComments = x)
+ );
+ subscribe(
+ this,
+ this.commentsModel.threads$,
+ x => (this.commentThreads = x)
+ );
+ subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
}
static override get styles() {
@@ -559,7 +580,7 @@
}
private handleAction(e: CustomEvent<Action>) {
- this.checksService.triggerAction(e.detail);
+ this.checksModel.triggerAction(e.detail);
}
private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
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 7d8bb86..1d66aa5 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
@@ -141,7 +141,7 @@
isDraftThread,
isRobot,
isUnresolved,
- UIDraft,
+ DraftInfo,
} from '../../../utils/comment-util';
import {
PolymerDeepPropertyChange,
@@ -180,12 +180,10 @@
fireReload,
fireTitleChange,
} from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
-import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
+import {GerritView} from '../../../services/router/router-model';
import {
debounce,
DelayedTask,
- isFalse,
throttleWrap,
until,
} from '../../../utils/async-util';
@@ -193,17 +191,12 @@
import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
import {getRevertCreatedChangeIds} from '../../../utils/message-util';
import {
- changeComments$,
- drafts$,
-} from '../../../services/comments/comments-model';
-import {
getAddedByReason,
getRemovedByReason,
hasAttention,
} from '../../../utils/attention-set-util';
import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {preferenceDiffViewMode$} from '../../../services/user/user-model';
-import {change$, changeLoading$} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
@@ -280,13 +273,6 @@
* @event show-auth-required
*/
- // Accessed in tests.
- readonly reporting = getAppContext().reportingService;
-
- readonly jsAPI = getAppContext().jsApiService;
-
- private readonly changeService = getAppContext().changeService;
-
/**
* URL params passed from the router.
*/
@@ -365,7 +351,7 @@
_changeNum?: NumericChangeId;
@property({type: Object})
- _diffDrafts?: {[path: string]: UIDraft[]} = {};
+ _diffDrafts?: {[path: string]: DraftInfo[]} = {};
@property({type: Boolean})
_editingCommitMessage = false;
@@ -393,9 +379,6 @@
@property({type: Object})
_messages = NO_ROBOT_COMMENTS_THREADS_MSG;
- @property({type: Number})
- _lineHeight?: number;
-
@property({type: Object})
_patchRange?: ChangeViewPatchRange;
@@ -560,14 +543,6 @@
})
resolveWeblinks?: GeneratedWebLink[];
- readonly restApiService = getAppContext().restApiService;
-
- private readonly userService = getAppContext().userService;
-
- private readonly commentsService = getAppContext().commentsService;
-
- private readonly shortcuts = getAppContext().shortcutsService;
-
override keyboardShortcuts(): ShortcutListener[] {
return [
listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -614,6 +589,27 @@
];
}
+ // Accessed in tests.
+ readonly reporting = getAppContext().reportingService;
+
+ readonly jsAPI = getAppContext().jsApiService;
+
+ private readonly checksModel = getAppContext().checksModel;
+
+ readonly restApiService = getAppContext().restApiService;
+
+ // Private but used in tests.
+ readonly userModel = getAppContext().userModel;
+
+ // Private but used in tests.
+ readonly changeModel = getAppContext().changeModel;
+
+ private readonly routerModel = getAppContext().routerModel;
+
+ private readonly commentsModel = getAppContext().commentsModel;
+
+ private readonly shortcuts = getAppContext().shortcutsService;
+
private subscriptions: Subscription[] = [];
private replyRefitTask?: DelayedTask;
@@ -633,32 +629,32 @@
override ready() {
super.ready();
this.subscriptions.push(
- aPluginHasRegistered$.subscribe(b => {
+ this.checksModel.aPluginHasRegistered$.subscribe(b => {
this._showChecksTab = b;
})
);
this.subscriptions.push(
- routerView$.subscribe(view => {
+ this.routerModel.routerView$.subscribe(view => {
this.isViewCurrent = view === GerritView.CHANGE;
})
);
this.subscriptions.push(
- drafts$.subscribe(drafts => {
+ this.commentsModel.drafts$.subscribe(drafts => {
this._diffDrafts = {...drafts};
})
);
this.subscriptions.push(
- preferenceDiffViewMode$.subscribe(diffViewMode => {
+ this.userModel.preferenceDiffViewMode$.subscribe(diffViewMode => {
this.diffViewMode = diffViewMode;
})
);
this.subscriptions.push(
- changeComments$.subscribe(changeComments => {
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this._changeComments = changeComments;
})
);
this.subscriptions.push(
- change$.subscribe(change => {
+ this.changeModel.change$.subscribe(change => {
// The change view is tied to a specific change number, so don't update
// _change to undefined.
if (change) this._change = change;
@@ -790,9 +786,9 @@
_handleToggleDiffMode() {
if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
- this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+ this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
} else {
- this.userService.updatePreferences({
+ this.userModel.updatePreferences({
diff_view: DiffViewMode.SIDE_BY_SIDE,
});
}
@@ -886,6 +882,13 @@
this._tabState = e.detail.tabState;
}
+ /**
+ * Currently there is a bug in this code where this.unresolvedOnly is only
+ * assigned the correct value when _onPaperTabClick is triggered which is
+ * only triggered when user explicitly clicks on the tab however the comments
+ * tab can also be opened via the url in which case the correct value to
+ * unresolvedOnly is never assigned.
+ */
_onPaperTabClick(e: MouseEvent) {
let target = e.target as HTMLElement | null;
let tabName: string | undefined;
@@ -897,7 +900,8 @@
} while (target);
if (tabName === PrimaryTab.COMMENT_THREADS) {
- // Show unresolved threads by default only if they are present
+ // Show unresolved threads by default
+ // Show resolved threads only if no unresolved threads exist
const hasUnresolvedThreads =
(this._commentThreads ?? []).filter(thread => isUnresolved(thread))
.length > 0;
@@ -1344,7 +1348,6 @@
_performPostLoadTasks() {
this._maybeShowReplyDialog();
this._maybeShowRevertDialog();
- this._maybeShowDownloadDialog();
this._sendShowChangeEvent();
@@ -1459,13 +1462,6 @@
});
}
- _maybeShowDownloadDialog() {
- if (this.viewState.showDownloadDialog) {
- this._handleOpenDownloadDialog();
- this.set('viewState.showDownloadDialog', false);
- }
- }
-
_resetFileListViewState() {
this.set('viewState.selectedFileIndex', 0);
if (
@@ -1538,7 +1534,7 @@
}
_computeReplyButtonLabel(
- drafts?: {[path: string]: UIDraft[]},
+ drafts?: {[path: string]: DraftInfo[]},
canStartReview?: boolean
) {
if (drafts === undefined || canStartReview === undefined) {
@@ -1887,7 +1883,10 @@
throw new Error('missing required changeNum property');
}
- const detailCompletes = until(changeLoading$, isFalse);
+ const detailCompletes = until(
+ this.changeModel.changeLoadingStatus$,
+ status => status === LoadingStatus.LOADED
+ );
const editCompletes = this._getEdit();
const prefCompletes = this._getPreferences();
@@ -1919,11 +1918,6 @@
this._latestCommitMessage = null;
}
- const lineHeight = getComputedStyle(this).lineHeight;
-
- // Slice returns a number as a string, convert to an int.
- this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
-
this.computeRevertSubmitted(this._change);
if (
!this._patchRange ||
@@ -2223,13 +2217,13 @@
const promises = [this._getCommitInfo(), this.$.fileList.reload()];
if (patchNumChanged) {
promises.push(
- this.commentsService.reloadPortedComments(
+ this.commentsModel.reloadPortedComments(
this._changeNum,
this._patchRange?.patchNum
)
);
promises.push(
- this.commentsService.reloadPortedDrafts(
+ this.commentsModel.reloadPortedDrafts(
this._changeNum,
this._patchRange?.patchNum
)
@@ -2334,13 +2328,12 @@
}
this._updateCheckTimerHandle = window.setTimeout(() => {
- if (!this.isViewCurrent) {
+ if (!this.isViewCurrent || !this._change) {
this._startUpdateCheckTimer();
return;
}
- assertIsDefined(this._change, '_change');
const change = this._change;
- this.changeService.fetchChangeUpdates(change).then(result => {
+ this.changeModel.fetchChangeUpdates(change).then(result => {
let toastMessage = null;
if (!result.isLatest) {
toastMessage = ReloadToastMessage.NEWER_REVISION;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index eea6d3a..13fb5b3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -565,10 +565,6 @@
<h3 class="assistive-tech-only">Comments</h3>
<gr-thread-list
threads="[[_commentThreads]]"
- change="[[_change]]"
- change-num="[[_changeNum]]"
- logged-in="[[_loggedIn]]"
- account="[[_account]]"
comment-tab-state="[[_tabState.commentTab]]"
only-show-robot-comments-with-human-reply=""
unresolved-only="[[unresolvedOnly]]"
@@ -597,14 +593,7 @@
value="[[_currentRobotCommentsPatchSet]]"
>
</gr-dropdown-list>
- <gr-thread-list
- threads="[[_robotCommentThreads]]"
- change="[[_change]]"
- change-num="[[_changeNum]]"
- logged-in="[[_loggedIn]]"
- hide-dropdown
- empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
- >
+ <gr-thread-list threads="[[_robotCommentThreads]]" hide-dropdown>
</gr-thread-list>
<template is="dom-if" if="[[_showRobotCommentsButton]]">
<gr-button
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 6104356..e8cce2d 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
@@ -17,6 +17,7 @@
import '../../../test/common-test-setup-karma';
import '../../edit/gr-edit-constants';
+import '../gr-thread-list/gr-thread-list';
import './gr-change-view';
import {
ChangeStatus,
@@ -27,16 +28,11 @@
MessageTag,
PrimaryTab,
createDefaultPreferences,
- createDefaultDiffPrefs,
} from '../../../constants/constants';
import {GrEditConstants} from '../../edit/gr-edit-constants';
import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
- _testOnly_initGerritPluginApi,
- GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
import {EventType, PluginApi} from '../../../api/plugin';
import 'lodash/lodash';
@@ -91,6 +87,7 @@
RevisionInfo,
RevisionPatchSetNum,
RobotId,
+ RobotCommentInfo,
Timestamp,
UrlEncodedCommentId,
} from '../../../types/common';
@@ -102,22 +99,21 @@
import {AppElementChangeViewParams} from '../../gr-app-types';
import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread, UIRobot} from '../../../utils/comment-util';
+import {CommentThread} from '../../../utils/comment-util';
import {GerritView} from '../../../services/router/router-model';
import {ParsedChangeInfo} from '../../../types/types';
import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {_testOnly_setState as setUserState} from '../../../services/user/user-model';
-import {_testOnly_setState as setChangeState} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
const fixture = fixtureFromElement('gr-change-view');
suite('gr-change-view tests', () => {
let element: GrChangeView;
- let pluginApi: GerritInternal;
let navigateToChangeStub: SinonStubbedMember<
typeof GerritNav.navigateToChange
@@ -167,8 +163,6 @@
message: 'draft',
unresolved: false,
__draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
patch_set: 2 as PatchSetNum,
},
],
@@ -271,8 +265,6 @@
message: 'resolved draft',
unresolved: false,
__draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
patch_set: 2 as PatchSetNum,
},
],
@@ -347,7 +339,6 @@
];
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
// Since pluginEndpoints are global, must reset state.
_testOnly_resetEndpoints();
navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
@@ -371,7 +362,7 @@
element._changeNum = TEST_NUMERIC_CHANGE_ID;
sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
getPluginLoader().loadPlugins([]);
- pluginApi.install(
+ window.Gerrit.install(
plugin => {
plugin.registerDynamicCustomComponent(
'change-view-tab-header',
@@ -818,10 +809,7 @@
...createDefaultPreferences(),
diff_view: DiffViewMode.SIDE_BY_SIDE,
};
- setUserState({
- preferences: prefs,
- diffPreferences: createDefaultDiffPrefs(),
- });
+ element.userModel.setPreferences(prefs);
element._handleToggleDiffMode();
assert.isTrue(
updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
@@ -831,10 +819,7 @@
...createDefaultPreferences(),
diff_view: DiffViewMode.UNIFIED,
};
- setUserState({
- preferences: newPrefs,
- diffPreferences: createDefaultDiffPrefs(),
- });
+ element.userModel.setPreferences(newPrefs);
await flush();
element._handleToggleDiffMode();
assert.isTrue(
@@ -879,7 +864,43 @@
});
});
- suite('Findings comment tab', () => {
+ suite('Comments tab', () => {
+ setup(async () => {
+ element._changeNum = TEST_NUMERIC_CHANGE_ID;
+ element._change = {
+ ...createChangeViewChange(),
+ revisions: {
+ rev2: createRevision(2),
+ rev1: createRevision(1),
+ rev13: createRevision(13),
+ rev3: createRevision(3),
+ rev4: createRevision(4),
+ },
+ current_revision: 'rev4' as CommitId,
+ };
+ element._commentThreads = THREADS;
+ await flush();
+ const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+ tap(paperTabs.querySelectorAll('paper-tab')[1]);
+ await flush();
+ });
+
+ test('commentId overrides unresolveOnly default', async () => {
+ const threadList = queryAndAssert<GrThreadList>(
+ element,
+ 'gr-thread-list'
+ );
+ assert.isTrue(element.unresolvedOnly);
+ assert.isNotOk(element.scrollCommentId);
+ assert.isTrue(threadList.unresolvedOnly);
+
+ element.scrollCommentId = 'abcd' as UrlEncodedCommentId;
+ await flush();
+ assert.isFalse(threadList.unresolvedOnly);
+ });
+ });
+
+ suite('Findings robot-comment tab', () => {
setup(async () => {
element._changeNum = TEST_NUMERIC_CHANGE_ID;
element._change = {
@@ -925,11 +946,13 @@
test('only robot comments are rendered', () => {
assert.equal(element._robotCommentThreads!.length, 2);
assert.equal(
- (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+ (element._robotCommentThreads![0].comments[0] as RobotCommentInfo)
+ .robot_id,
'rc1'
);
assert.equal(
- (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+ (element._robotCommentThreads![1].comments[0] as RobotCommentInfo)
+ .robot_id,
'rc2'
);
});
@@ -1498,7 +1521,8 @@
test('topic is coalesced to null', async () => {
sinon.stub(element, '_changeChanged');
- setChangeState({
+ element.changeModel.setState({
+ loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
labels: {},
@@ -1513,7 +1537,8 @@
test('commit sha is populated from getChangeDetail', async () => {
sinon.stub(element, '_changeChanged');
- setChangeState({
+ element.changeModel.setState({
+ loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
labels: {},
@@ -1529,7 +1554,8 @@
test('edit is added to change', () => {
sinon.stub(element, '_changeChanged');
const changeRevision = createRevision();
- setChangeState({
+ element.changeModel.setState({
+ loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
labels: {},
@@ -1745,6 +1771,7 @@
'#replyDialog'
);
const openSpy = sinon.spy(dialog, 'open');
+ await flush();
await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
});
@@ -1948,7 +1975,8 @@
test('_selectedRevision updates when patchNum is changed', () => {
const revision1: RevisionInfo = createRevision(1);
const revision2: RevisionInfo = createRevision(2);
- setChangeState({
+ element.changeModel.setState({
+ loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
revisions: {
@@ -1978,7 +2006,8 @@
const revision1 = createRevision(1);
const revision2 = createRevision(2);
const revision3 = createEditRevision();
- setChangeState({
+ element.changeModel.setState({
+ loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
revisions: {
@@ -2143,7 +2172,11 @@
element._change = {...createChangeViewChange(), labels: {}};
element._selectedRevision = createRevision();
const promise = mockPromise();
- pluginApi.install(promise.resolve, '0.1', 'http://some/plugins/url.js');
+ window.Gerrit.install(
+ promise.resolve,
+ '0.1',
+ 'http://some/plugins/url.js'
+ );
await flush();
const plugin: PluginApi = (await promise) as PluginApi;
const hookEl = await plugin
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index a9b7b81..c24e054 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -29,8 +29,7 @@
import {customElement, property, query, state} from 'lit/decorators';
import {fontStyles} from '../../../styles/gr-font-styles';
import {subscribe} from '../../lit/subscription-controller';
-import {change$} from '../../../services/change/change-model';
-import {threads$} from '../../../services/comments/comments-model';
+import {getAppContext} from '../../../services/app-context';
import {ParsedChangeInfo} from '../../../types/types';
@customElement('gr-confirm-submit-dialog')
@@ -62,6 +61,10 @@
@state()
initialised = false;
+ private commentsModel = getAppContext().commentsModel;
+
+ private changeModel = getAppContext().changeModel;
+
static override get styles() {
return [
sharedStyles,
@@ -90,10 +93,10 @@
constructor() {
super();
- subscribe(this, change$, x => (this.change = x));
+ subscribe(this, this.changeModel.change$, x => (this.change = x));
subscribe(
this,
- threads$,
+ this.commentsModel.threads$,
x => (this.unresolvedThreads = x.filter(isUnresolved))
);
}
@@ -125,9 +128,6 @@
<gr-thread-list
id="commentList"
.threads="${this.unresolvedThreads}"
- .change="${this.change}"
- .changeNum="${this.change?._number}"
- logged-in
hide-dropdown
>
</gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index f400814..112077a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -83,12 +83,8 @@
import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
import {Timing} from '../../../constants/reporting';
import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {
- diffPreferences$,
- sizeBarInChangeTable$,
-} from '../../../services/user/user-model';
-import {changeComments$} from '../../../services/comments/comments-model';
import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {select} from '../../../utils/observable-util';
export const DEFAULT_NUM_FILES_SHOWN = 200;
@@ -317,7 +313,9 @@
private readonly restApiService = getAppContext().restApiService;
- private readonly userService = getAppContext().userService;
+ private readonly userModel = getAppContext().userModel;
+
+ private readonly commentsModel = getAppContext().commentsModel;
private readonly browserModel = getAppContext().browserModel;
@@ -377,26 +375,23 @@
override connectedCallback() {
super.connectedCallback();
- this.subscriptions.push(
- changeComments$.subscribe(changeComments => {
+ this.subscriptions = [
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this.changeComments = changeComments;
- })
- );
- this.subscriptions.push(
+ }),
this.browserModel.diffViewMode$.subscribe(
diffView => (this.diffViewMode = diffView)
- )
- );
- this.subscriptions.push(
- diffPreferences$.subscribe(diffPreferences => {
+ ),
+ this.userModel.diffPreferences$.subscribe(diffPreferences => {
this.diffPrefs = diffPreferences;
- })
- );
- this.subscriptions.push(
- sizeBarInChangeTable$.subscribe(sizeBarInChangeTable => {
+ }),
+ select(
+ this.userModel.preferences$,
+ prefs => !!prefs?.size_bar_in_change_table
+ ).subscribe(sizeBarInChangeTable => {
this._showSizeBars = sizeBarInChangeTable;
- })
- );
+ }),
+ ];
getPluginLoader()
.awaitPluginsLoaded()
@@ -1648,7 +1643,7 @@
}
_handleReloadingDiffPreference() {
- this.userService.getDiffPreferences();
+ this.userModel.getDiffPreferences();
}
/**
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 817c21b..fd6b0d4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -1469,18 +1469,11 @@
ignore_whitespace: 'IGNORE_NONE',
};
diff.diff = getMockDiffResponse();
- sinon.stub(diff.changeComments, 'getCommentsForPath')
- .withArgs('/COMMIT_MSG', {
- basePatchNum: 'PARENT',
- patchNum: 2,
- })
- .returns(diff.comments);
await listenOnce(diff, 'render');
}
async function renderAndGetNewDiffs(index) {
- const diffs =
- element.root.querySelectorAll('gr-diff-host');
+ const diffs = element.root.querySelectorAll('gr-diff-host');
for (let i = index; i < diffs.length; i++) {
await setupDiff(diffs[i]);
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 3c4702c..be04e6e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -33,10 +33,13 @@
LabelValuesMap,
} from '../gr-label-score-row/gr-label-score-row';
import {getAppContext} from '../../../services/app-context';
-import {getTriggerVotes, labelCompare} from '../../../utils/label-util';
+import {
+ getTriggerVotes,
+ labelCompare,
+ showNewSubmitRequirements,
+} from '../../../utils/label-util';
import {Execution} from '../../../constants/reporting';
import {ChangeStatus} from '../../../constants/constants';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {fontStyles} from '../../../styles/gr-font-styles';
@customElement('gr-label-scores')
@@ -90,7 +93,7 @@
}
override render() {
- if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+ if (showNewSubmitRequirements(this.flagsService, this.change)) {
return this.renderNewSubmitRequirements();
} else {
return this.renderOldSubmitRequirements();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index a512b60..5d74d07 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -555,8 +555,10 @@
@observe('projectName')
_projectNameChanged(name?: string) {
- // Check if name is undefined to prevent errors.
- if (!name) return;
+ if (!name) {
+ this._projectConfig = undefined;
+ return;
+ }
this.restApiService.getProjectConfig(name as RepoName).then(config => {
this._projectConfig = config;
});
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 7f3e9de..8def279 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -280,13 +280,11 @@
</div>
</template>
<gr-thread-list
- change="[[change]]"
hidden$="[[!commentThreads.length]]"
threads="[[commentThreads]]"
- change-num="[[changeNum]]"
- logged-in="[[_loggedIn]]"
hide-dropdown
show-comment-context
+ message-id="[[message.id]]"
>
</gr-thread-list>
</template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index bd51aab..36d0ce6 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -51,13 +51,6 @@
FormattedReviewerUpdateInfo,
ParsedChangeInfo,
} from '../../../types/types';
-import {threads$} from '../../../services/comments/comments-model';
-import {
- change$,
- changeNum$,
- repo$,
-} from '../../../services/change/change-model';
-import {loggedIn$} from '../../../services/user/user-model';
/**
* The content of the enum is also used in the UI for the button text.
@@ -101,17 +94,10 @@
message: CombinedMessage,
allThreadsForChange: CommentThread[]
): CommentThread[] {
- if (message._index === undefined) {
- return [];
- }
+ if (message._index === undefined) return [];
const messageId = getMessageId(message);
return allThreadsForChange.filter(thread =>
- thread.comments.some(comment => {
- const matchesMessage = comment.change_message_id === messageId;
- if (!matchesMessage) return false;
- comment.collapsed = !matchesMessage;
- return matchesMessage;
- })
+ thread.comments.some(comment => comment.change_message_id === messageId)
);
}
@@ -263,6 +249,13 @@
@property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
_labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
+ private readonly userModel = getAppContext().userModel;
+
+ // Private but used in tests.
+ readonly commentsModel = getAppContext().commentsModel;
+
+ private readonly changeModel = getAppContext().changeModel;
+
private readonly reporting = getAppContext().reportingService;
private readonly shortcuts = getAppContext().shortcutsService;
@@ -272,27 +265,27 @@
override connectedCallback() {
super.connectedCallback();
this.subscriptions.push(
- threads$.subscribe(x => {
+ this.commentsModel.threads$.subscribe(x => {
this.commentThreads = x;
})
);
this.subscriptions.push(
- change$.subscribe(x => {
+ this.changeModel.change$.subscribe(x => {
this.change = x;
})
);
this.subscriptions.push(
- loggedIn$.subscribe(x => {
+ this.userModel.loggedIn$.subscribe(x => {
this.showReplyButtons = x;
})
);
this.subscriptions.push(
- repo$.subscribe(x => {
+ this.changeModel.repo$.subscribe(x => {
this.projectName = x;
})
);
this.subscriptions.push(
- changeNum$.subscribe(x => {
+ this.changeModel.changeNum$.subscribe(x => {
this.changeNum = x;
})
);
@@ -391,13 +384,6 @@
}
}
- // collapse all by default
- for (const thread of commentThreads) {
- for (const comment of thread.comments) {
- comment.collapsed = true;
- }
- }
-
for (let i = 0; i < combinedMessages.length; i++) {
const message = combinedMessages[i];
if (message.expanded === undefined) {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index 50e1a38..65c3c74 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -22,7 +22,6 @@
import {MessageTag} from '../../../constants/constants.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {stubRestApi} from '../../../test/test-utils.js';
-import {updateStateComments} from '../../../services/comments/comments-model.js';
createCommentApiMockWithTemplateElement(
'gr-messages-list-comment-mock-api', html`
@@ -129,7 +128,7 @@
};
suite('basic tests', () => {
- setup(() => {
+ setup(async () => {
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
stubRestApi('getDiffComments').returns(Promise.resolve(comments));
stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -140,9 +139,9 @@
// comment API.
commentApiWrapper = basicFixture.instantiate();
element = commentApiWrapper.$.messagesList;
- updateStateComments(comments);
+ await element.commentsModel.reloadComments();
element.messages = messages;
- flush();
+ await flush();
});
test('expand/collapse all', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 7ab6108..c882764 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -620,7 +620,10 @@
this.restApiService.getConfig().then(config => {
if (config && !config.change.submit_whole_topic) {
return this.restApiService
- .getChangesWithSameTopic(changeTopic, change._number)
+ .getChangesWithSameTopic(changeTopic, {
+ openChangesOnly: true,
+ changeToExclude: change._number,
+ })
.then(response => {
if (changeTopic === this.change?.topic) {
this.sameTopicChanges = response ?? [];
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 68d48b7..94b8668 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -47,10 +47,6 @@
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../types/types';
import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {
- _testOnly_initGerritPluginApi,
- GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import './gr-related-changes-list';
import {
@@ -64,10 +60,8 @@
suite('gr-related-changes-list', () => {
let element: GrRelatedChangesList;
- let pluginApi: GerritInternal;
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
element = basicFixture.instantiate();
});
@@ -609,7 +603,7 @@
}
let hookEl: RelatedChangesListGrEndpointDecorator;
let plugin: PluginApi;
- pluginApi.install(
+ window.Gerrit.install(
p => {
plugin = p;
plugin
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 8980642..8e78d4e 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -19,14 +19,12 @@
import './gr-reply-dialog.js';
import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
const basicFixture = fixtureFromElement('gr-reply-dialog');
suite('gr-reply-dialog-it tests', () => {
let element;
- let pluginApi;
let changeNum;
let patchNum;
@@ -71,7 +69,6 @@
};
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
changeNum = 42;
patchNum = 1;
@@ -102,7 +99,7 @@
test('lgtm plugin', async () => {
resetPlugins();
- pluginApi.install(plugin => {
+ window.Gerrit.install(plugin => {
const replyApi = plugin.changeReply();
replyApi.addReplyTextChangedCallback(text => {
const label = 'Code-Review';
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index dca0d65..52a5dac 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -218,7 +218,7 @@
private readonly reporting = getAppContext().reportingService;
- private readonly changeService = getAppContext().changeService;
+ private readonly changeModel = getAppContext().changeModel;
@property({type: Object})
change?: ChangeInfo;
@@ -435,7 +435,7 @@
open(focusTarget?: FocusTarget, quote?: string) {
assertIsDefined(this.change, 'change');
this.knownLatestState = LatestPatchState.CHECKING;
- this.changeService.fetchChangeUpdates(this.change).then(result => {
+ this.changeModel.fetchChangeUpdates(this.change).then(result => {
this.knownLatestState = result.isLatest
? LatestPatchState.LATEST
: LatestPatchState.NOT_LATEST;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 4a8b996..719347c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -394,9 +394,6 @@
id="commentList"
hidden$="[[!_includeComments]]"
threads="[[draftCommentThreads]]"
- change="[[change]]"
- change-num="[[change._number]]"
- logged-in="true"
hide-dropdown=""
>
</gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index df28175..14ad4ad 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -35,6 +35,7 @@
import {
createAccountWithId,
createChange,
+ createComment,
createCommentThread,
createDraft,
createRevision,
@@ -318,18 +319,13 @@
if (hasDraft) {
draftThreads = [
{
- ...createCommentThread([
- {
- ...createDraft(),
- __draft: true,
- unresolved: true,
- },
- ]),
+ ...createCommentThread([{...createDraft(), unresolved: true}]),
},
];
}
replyToIds?.forEach(id =>
draftThreads[0].comments.push({
+ ...createComment(),
author: {_account_id: id},
})
);
@@ -878,11 +874,13 @@
{
...createCommentThread([
{
+ ...createComment(),
id: '1' as UrlEncodedCommentId,
author: {_account_id: 1 as AccountId},
unresolved: false,
},
{
+ ...createComment(),
id: '2' as UrlEncodedCommentId,
in_reply_to: '1' as UrlEncodedCommentId,
author: {_account_id: 2 as AccountId},
@@ -893,11 +891,13 @@
{
...createCommentThread([
{
+ ...createComment(),
id: '3' as UrlEncodedCommentId,
author: {_account_id: 3 as AccountId},
unresolved: false,
},
{
+ ...createComment(),
id: '4' as UrlEncodedCommentId,
in_reply_to: '3' as UrlEncodedCommentId,
author: {_account_id: 4 as AccountId},
@@ -2003,7 +2003,7 @@
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ [
- {...createCommentThread([{__draft: true}])},
+ {...createCommentThread([createComment()])},
],
/* text= */ '',
/* reviewersMutated= */ false,
@@ -2023,7 +2023,7 @@
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ [
- {...createCommentThread([{__draft: true}])},
+ {...createCommentThread([createComment()])},
],
/* text= */ '',
/* reviewersMutated= */ false,
@@ -2042,7 +2042,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ 'test',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
@@ -2060,7 +2062,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ true,
/* labelsChanged= */ false,
@@ -2078,7 +2082,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ true,
@@ -2096,7 +2102,9 @@
assert.isTrue(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ true,
@@ -2120,7 +2128,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
@@ -2144,7 +2154,12 @@
element.draftCommentThreads = [
{
...createCommentThread([
- {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+ {
+ ...createDraft(),
+ path: 'test',
+ line: 1,
+ patch_set: 1 as PatchSetNum,
+ },
]),
},
];
@@ -2167,7 +2182,12 @@
element.draftCommentThreads = [
{
...createCommentThread([
- {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+ {
+ ...createDraft(),
+ path: 'test',
+ line: 1,
+ patch_set: 1 as PatchSetNum,
+ },
]),
},
];
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index e5f81b3..38230e2 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -109,11 +109,12 @@
.expression {
color: var(--gray-foreground);
}
- iron-icon.check {
+ iron-icon.check,
+ iron-icon.overridden {
color: var(--success-foreground);
}
iron-icon.close {
- color: var(--warning-foreground);
+ color: var(--error-foreground);
}
.showConditions iron-icon {
color: inherit;
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index ccde670..3fc2e0f 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -18,6 +18,7 @@
import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
import '../gr-change-summary/gr-change-summary';
+import '../../shared/gr-limited-text/gr-limited-text';
import {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {notUndefined, ParsedChangeInfo} from '../../../types/types';
@@ -43,10 +44,7 @@
import {fontStyles} from '../../../styles/gr-font-styles';
import {charsOnly} from '../../../utils/string-util';
import {subscribe} from '../../lit/subscription-controller';
-import {
- allRunsLatestPatchsetLatestAttempt$,
- CheckRun,
-} from '../../../services/checks/checks-model';
+import {CheckRun} from '../../../services/checks/checks-model';
import {
firstPrimaryLink,
getResultsOf,
@@ -56,6 +54,7 @@
import '../../shared/gr-vote-chip/gr-vote-chip';
import {fireShowPrimaryTab} from '../../../utils/event-util';
import {PrimaryTab} from '../../../constants/constants';
+import {getAppContext} from '../../../services/app-context';
/**
* @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -148,9 +147,15 @@
];
}
+ private readonly checksModel = getAppContext().checksModel;
+
constructor() {
super();
- subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+ subscribe(
+ this,
+ this.checksModel.allRunsLatestPatchsetLatestAttempt$,
+ x => (this.runs = x)
+ );
}
override render() {
@@ -220,7 +225,8 @@
name="requirement"
.value=${requirement}
></gr-endpoint-param>
- ${this.renderVotes(requirement)}${this.renderChecks(requirement)}
+ ${this.renderVotesAndChecksChips(requirement)}
+ ${this.renderOverrideLabels(requirement)}
</gr-endpoint-decorator>
</td>
</tr>
@@ -237,7 +243,7 @@
></iron-icon>`;
}
- renderVotes(requirement: SubmitRequirementResultInfo) {
+ renderVotesAndChecksChips(requirement: SubmitRequirementResultInfo) {
const requirementLabels = extractAssociatedLabels(requirement);
const allLabels = this.change?.labels ?? {};
const associatedLabels = Object.keys(allLabels).filter(label =>
@@ -247,11 +253,17 @@
const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
label => !hasVotes(allLabels[label])
);
- if (everyAssociatedLabelsIsWithoutVotes) return html`No votes`;
- return associatedLabels.map(label =>
+ const checksChips = this.renderChecks(requirement);
+
+ if (everyAssociatedLabelsIsWithoutVotes) {
+ return checksChips || html`No votes`;
+ }
+
+ return html`${associatedLabels.map(label =>
this.renderLabelVote(label, allLabels)
- );
+ )}
+ ${checksChips}`;
}
renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -288,27 +300,39 @@
(sum, run) => sum + getResultsOf(run, Category.ERROR).length,
0
);
- if (runsCount > 0) {
- const allPrimaryLinks = requirementRuns
- .map(run => run.results ?? [])
- .flat()
- .map(firstPrimaryLink)
- .filter(notUndefined);
- const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
- return html`<gr-checks-chip
- .text=${`${runsCount}`}
- .links=${links}
- .statusOrCategory=${Category.ERROR}
- @click="${() => {
- fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
- checksTab: {
- statusOrCategory: Category.ERROR,
- },
- });
- }}"
- ></gr-checks-chip>`;
- }
- return;
+ if (runsCount === 0) return;
+ const allPrimaryLinks = requirementRuns
+ .map(run => run.results ?? [])
+ .flat()
+ .map(firstPrimaryLink)
+ .filter(notUndefined);
+ const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
+ return html`<gr-checks-chip
+ .text=${`${runsCount}`}
+ .links=${links}
+ .statusOrCategory=${Category.ERROR}
+ @click="${() => {
+ fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
+ checksTab: {
+ statusOrCategory: Category.ERROR,
+ },
+ });
+ }}"
+ ></gr-checks-chip>`;
+ }
+
+ renderOverrideLabels(requirement: SubmitRequirementResultInfo) {
+ if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
+ const requirementLabels = extractAssociatedLabels(
+ requirement,
+ 'onlyOverride'
+ ).filter(label => {
+ const allLabels = this.change?.labels ?? {};
+ return allLabels[label] && hasVotes(allLabels[label]);
+ });
+ return requirementLabels.map(
+ label => html`<span class="overrideLabel">${label}</span>`
+ );
}
renderTriggerVotes() {
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
new file mode 100644
index 0000000..794d7cca9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -0,0 +1,118 @@
+/**
+ * @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 {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-submit-requirements';
+import {GrSubmitRequirements} from './gr-submit-requirements';
+import {
+ createAccountWithIdNameAndEmail,
+ createApproval,
+ createDetailedLabelInfo,
+ createParsedChange,
+ createSubmitRequirementExpressionInfo,
+ createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-submit-requirements tests', () => {
+ let element: GrSubmitRequirements;
+ setup(async () => {
+ const submitRequirement: SubmitRequirementResultInfo = {
+ ...createSubmitRequirementResultInfo(),
+ description: 'Test Description',
+ submittability_expression_result: {
+ ...createSubmitRequirementExpressionInfo(),
+ expression: 'label:Verified=MAX -label:Verified=MIN',
+ },
+ };
+ const change: ParsedChangeInfo = {
+ ...createParsedChange(),
+ submit_requirements: [submitRequirement],
+ labels: {
+ Verified: {
+ ...createDetailedLabelInfo(),
+ all: [
+ {
+ ...createApproval(),
+ value: 2,
+ },
+ ],
+ },
+ },
+ };
+ const account = createAccountWithIdNameAndEmail();
+ element = await fixture<GrSubmitRequirements>(
+ html`<gr-submit-requirements
+ .change=${change}
+ .account=${account}
+ ></gr-submit-requirements>`
+ );
+ });
+
+ test('renders', () => {
+ expect(element).shadowDom.to.equal(`<h3
+ class="heading-3 metadata-title"
+ id="submit-requirements-caption"
+ >
+ Submit Requirements
+ </h3>
+ <table
+ aria-labelledby="submit-requirements-caption"
+ class="requirements"
+ >
+ <thead hidden="">
+ <tr>
+ <th>Status</th>
+ <th>Name</th>
+ <th>Votes</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr id="requirement-Verified">
+ <td>
+ <iron-icon
+ aria-label="satisfied"
+ class="check"
+ icon="gr-icons:check"
+ role="img"
+ >
+ </iron-icon>
+ </td>
+ <td class="name">
+ <gr-limited-text class="name" limit="25"></gr-limited-text>
+ </td>
+ <td>
+ <gr-endpoint-decorator
+ class="votes-cell"
+ name="submit-requirement-verified"
+ >
+ <gr-endpoint-param name="change"></gr-endpoint-param>
+ <gr-endpoint-param name="requirement"></gr-endpoint-param>
+ <gr-vote-chip></gr-vote-chip>
+ </gr-endpoint-decorator>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <gr-submit-requirement-hovercard for="requirement-Verified">
+ </gr-submit-requirement-hovercard>
+ `);
+ });
+});
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 87c62b1..490162b 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,50 +17,36 @@
import '../../../styles/shared-styles';
import '../../shared/gr-comment-thread/gr-comment-thread';
import '../../shared/gr-dropdown-list/gr-dropdown-list';
-
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-thread-list_html';
-import {parseDate} from '../../../utils/date-util';
-
-import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {computed, customElement, observe, property} from '@polymer/decorators';
-import {
- PolymerSpliceChange,
- PolymerDeepPropertyChange,
-} from '@polymer/polymer/interfaces';
+import {SpecialFilePath} from '../../../constants/constants';
import {
AccountDetailInfo,
AccountInfo,
- ChangeInfo,
NumericChangeId,
UrlEncodedCommentId,
} from '../../../types/common';
+import {ChangeMessageId} from '../../../api/rest-api';
import {
CommentThread,
- isDraft,
- isUnresolved,
+ getCommentAuthors,
+ hasHumanReply,
isDraftThread,
isRobotThread,
- hasHumanReply,
- getCommentAuthors,
- computeId,
- UIComment,
+ isUnresolved,
+ lastUpdated,
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
-import {assertIsDefined, assertNever} from '../../../utils/common-util';
+import {assertIsDefined} from '../../../utils/common-util';
import {CommentTabState} from '../../../types/events';
import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-
-interface CommentThreadWithInfo {
- thread: CommentThread;
- hasRobotComment: boolean;
- hasHumanReplyToRobotComment: boolean;
- unresolved: boolean;
- isEditing: boolean;
- hasDraft: boolean;
- updated?: Date;
-}
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, queryAll, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ParsedChangeInfo} from '../../../types/types';
+import {repeat} from 'lit/directives/repeat';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {getAppContext} from '../../../services/app-context';
enum SortDropdownState {
TIMESTAMP = 'Latest timestamp',
@@ -69,573 +55,516 @@
export const __testOnly_SortDropdownState = SortDropdownState;
-@customElement('gr-thread-list')
-export class GrThreadList extends PolymerElement {
- static get template() {
- return htmlTemplate;
+/**
+ * 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
+ */
+export function compareThreads(
+ c1: CommentThread,
+ c2: CommentThread,
+ byTimestamp = false
+) {
+ if (byTimestamp) {
+ const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+ const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+ const timeDiff = c2Time - c1Time;
+ if (timeDiff !== 0) return c2Time - c1Time;
}
- @property({type: Object})
- change?: ChangeInfo;
+ if (c1.path !== c2.path) {
+ // '/PATCHSET' will not come before '/COMMIT' when sorting
+ // alphabetically so move it to the front explicitly
+ if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return -1;
+ }
+ if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return 1;
+ }
+ return c1.path.localeCompare(c2.path);
+ }
+ // Convert 'FILE' and 'LOST' to undefined.
+ const line1 = typeof c1.line === 'number' ? c1.line : undefined;
+ const line2 = typeof c2.line === 'number' ? c2.line : undefined;
+ if (line1 !== line2) {
+ // one of them is a FILE/LOST comment, show first
+ if (line1 === undefined) return -1;
+ if (line2 === undefined) return 1;
+ // Lower line numbers first.
+ return line1 < line2 ? -1 : 1;
+ }
+
+ if (c1.patchNum !== c2.patchNum) {
+ // `patchNum` should be required, but show undefined first.
+ if (c1.patchNum === undefined) return -1;
+ if (c2.patchNum === undefined) return 1;
+ // Higher patchset numbers first.
+ return c1.patchNum > c2.patchNum ? -1 : 1;
+ }
+
+ // Sorting should not be based on the thread being unresolved or being a draft
+ // thread, because that would be a surprising re-sort when the thread changes
+ // state.
+
+ const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+ const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+ if (c2Time !== c1Time) {
+ // Newer comments first.
+ return c2Time - c1Time;
+ }
+
+ return 0;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends LitElement {
+ @queryAll('gr-comment-thread')
+ threadElements?: NodeList;
+
+ /**
+ * Raw list of threads for the component to show.
+ *
+ * ATTENTION! this.threads should never be used directly within the component.
+ *
+ * Either use getAllThreads(), which applies filters that are inherent to what
+ * the component is supposed to render,
+ * e.g. onlyShowRobotCommentsWithHumanReply.
+ *
+ * Or use getDisplayedThreads(), which applies the currently selected filters
+ * on top.
+ */
@property({type: Array})
threads: CommentThread[] = [];
- @property({type: String})
- changeNum?: NumericChangeId;
-
- @property({type: Boolean})
- loggedIn?: boolean;
-
- @property({type: Array})
- _sortedThreads: CommentThread[] = [];
-
- @property({type: Boolean})
+ @property({type: Boolean, attribute: 'show-comment-context'})
showCommentContext = false;
- @property({
- computed:
- '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
- '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
- type: Array,
- })
- _displayedThreads: CommentThread[] = [];
-
- // thread-list is used in multiple places like the change log, hence
- // keeping the default to be false. When used in comments tab, it's
- // set as true.
- @property({type: Boolean})
+ /** Along with `draftsOnly` is the currently selected filter. */
+ @property({type: Boolean, attribute: 'unresolved-only'})
unresolvedOnly = false;
- @property({type: Boolean})
- _draftsOnly = false;
-
- @property({type: Boolean})
+ @property({
+ type: Boolean,
+ attribute: 'only-show-robot-comments-with-human-reply',
+ })
onlyShowRobotCommentsWithHumanReply = false;
- @property({type: Boolean})
+ @property({type: Boolean, attribute: 'hide-dropdown'})
hideDropdown = false;
- @property({type: Object, observer: '_commentTabStateChange'})
+ @property({type: Object, attribute: 'comment-tab-state'})
commentTabState?: CommentTabState;
- @property({type: Object})
- sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
-
- @property({type: Array, notify: true})
- selectedAuthors: AccountInfo[] = [];
-
- @property({type: Object})
- account?: AccountDetailInfo;
-
- @computed('unresolvedOnly', '_draftsOnly')
- get commentsDropdownValue() {
- // set initial value and triggered when comment summary chips are clicked
- if (this._draftsOnly) return CommentTabState.DRAFTS;
- return this.unresolvedOnly
- ? CommentTabState.UNRESOLVED
- : CommentTabState.SHOW_ALL;
- }
-
- @property({type: String})
+ @property({type: String, attribute: 'scroll-comment-id'})
scrollCommentId?: UrlEncodedCommentId;
- _showEmptyThreadsMessage(
- threads: CommentThread[],
- displayedThreads: CommentThread[],
- unresolvedOnly: boolean
- ) {
- if (!threads || !displayedThreads) return false;
- return !threads.length || (unresolvedOnly && !displayedThreads.length);
+ /**
+ * Optional context information when threads are being displayed for a
+ * specific change message. That influences which comments are expanded or
+ * collapsed by default.
+ */
+ @property({type: String, attribute: 'message-id'})
+ messageId?: ChangeMessageId;
+
+ @state()
+ changeNum?: NumericChangeId;
+
+ @state()
+ change?: ParsedChangeInfo;
+
+ @state()
+ account?: AccountDetailInfo;
+
+ @state()
+ selectedAuthors: AccountInfo[] = [];
+
+ @state()
+ sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+ /** Along with `unresolvedOnly` is the currently selected filter. */
+ @state()
+ draftsOnly = false;
+
+ private readonly changeModel = getAppContext().changeModel;
+
+ private readonly userModel = getAppContext().userModel;
+
+ constructor() {
+ super();
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+ subscribe(this, this.changeModel.change$, x => (this.change = x));
+ subscribe(this, this.userModel.account$, x => (this.account = x));
}
- _computeEmptyThreadsMessage(threads: CommentThread[]) {
- return !threads.length ? 'No comments' : 'No unresolved comments';
+ override willUpdate(changed: PropertyValues) {
+ if (changed.has('commentTabState')) this.onCommentTabStateUpdate();
+ if (changed.has('scrollCommentId')) this.onScrollCommentIdUpdate();
}
- _showPartyPopper(threads: CommentThread[]) {
- return !!threads.length;
- }
-
- _computeResolvedCommentsMessage(
- threads: CommentThread[],
- displayedThreads: CommentThread[],
- unresolvedOnly: boolean,
- onlyShowRobotCommentsWithHumanReply: boolean
- ) {
- if (onlyShowRobotCommentsWithHumanReply) {
- threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+ private onCommentTabStateUpdate() {
+ switch (this.commentTabState) {
+ case CommentTabState.UNRESOLVED:
+ this.handleOnlyUnresolved();
+ break;
+ case CommentTabState.DRAFTS:
+ this.handleOnlyDrafts();
+ break;
+ case CommentTabState.SHOW_ALL:
+ this.handleAllComments();
+ break;
}
- if (unresolvedOnly && threads.length && !displayedThreads.length) {
- return `Show ${pluralize(threads.length, 'resolved comment')}`;
- }
- return '';
}
- _showResolvedCommentsButton(
- threads: CommentThread[],
- displayedThreads: CommentThread[],
- unresolvedOnly: boolean
- ) {
- return unresolvedOnly && threads.length && !displayedThreads.length;
+ /**
+ * When user wants to scroll to a comment, render all comments so that the
+ * appropriate comment can be scrolled into view.
+ */
+ private onScrollCommentIdUpdate() {
+ if (this.scrollCommentId) this.handleAllComments();
}
- _handleResolvedCommentsMessageClick() {
- this.unresolvedOnly = !this.unresolvedOnly;
+ static override get styles() {
+ return [
+ sharedStyles,
+ css`
+ #threads {
+ display: block;
+ }
+ gr-comment-thread {
+ display: block;
+ margin-bottom: var(--spacing-m);
+ }
+ .header {
+ align-items: center;
+ background-color: var(--background-color-primary);
+ border-bottom: 1px solid var(--border-color);
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ justify-content: left;
+ padding: var(--spacing-s) var(--spacing-l);
+ }
+ .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+ .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+ .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
+ display: block;
+ }
+ .thread-separator {
+ border-top: 1px solid var(--border-color);
+ margin-top: var(--spacing-xl);
+ }
+ .show-resolved-comments {
+ box-shadow: none;
+ padding-left: var(--spacing-m);
+ }
+ .partypopper {
+ margin-right: var(--spacing-s);
+ }
+ gr-dropdown-list {
+ --trigger-style-text-color: var(--primary-text-color);
+ --trigger-style-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;
+ }
+ `,
+ ];
}
- getSortDropdownEntires() {
+ override render() {
+ return html`
+ ${this.renderDropdown()}
+ <div id="threads" part="threads">
+ ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
+ </div>
+ `;
+ }
+
+ private renderDropdown() {
+ if (this.hideDropdown) return;
+ return html`
+ <div class="header">
+ <span class="sort-text">Sort By:</span>
+ <gr-dropdown-list
+ id="sortDropdown"
+ .value="${this.sortDropdownValue}"
+ @value-change="${(e: CustomEvent) =>
+ (this.sortDropdownValue = e.detail.value)}"
+ .items="${this.getSortDropdownEntries()}"
+ >
+ </gr-dropdown-list>
+ <span class="separator"></span>
+ <span class="filter-text">Filter By:</span>
+ <gr-dropdown-list
+ id="filterDropdown"
+ .value="${this.getCommentsDropdownValue()}"
+ @value-change="${this.handleCommentsDropdownValueChange}"
+ .items="${this.getCommentsDropdownEntries()}"
+ >
+ </gr-dropdown-list>
+ ${this.renderAuthorChips()}
+ </div>
+ `;
+ }
+
+ private renderEmptyThreadsMessage() {
+ const threads = this.getAllThreads();
+ const threadsEmpty = threads.length === 0;
+ const displayedEmpty = this.getDisplayedThreads().length === 0;
+ if (!displayedEmpty) return;
+ const showPopper = this.unresolvedOnly && !threadsEmpty;
+ const popper = html`<span class="partypopper">🎉</span>`;
+ const showButton = this.unresolvedOnly && !threadsEmpty;
+ const button = html`
+ <gr-button
+ class="show-resolved-comments"
+ link
+ @click="${this.handleAllComments}"
+ >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
+ >
+ `;
+ return html`
+ <div>
+ <span>
+ ${showPopper ? popper : undefined}
+ ${threadsEmpty ? 'No comments' : 'No unresolved comments'}
+ ${showButton ? button : undefined}
+ </span>
+ </div>
+ `;
+ }
+
+ private renderCommentThreads() {
+ const threads = this.getDisplayedThreads();
+ return repeat(
+ threads,
+ thread => thread.rootId,
+ (thread, index) => {
+ const isFirst =
+ index === 0 || threads[index - 1].path !== threads[index].path;
+ const separator =
+ index !== 0 && isFirst
+ ? html`<div class="thread-separator"></div>`
+ : undefined;
+ const commentThread = this.renderCommentThread(thread, isFirst);
+ return html`${separator}${commentThread}`;
+ }
+ );
+ }
+
+ private renderCommentThread(thread: CommentThread, isFirst: boolean) {
+ return html`
+ <gr-comment-thread
+ .thread="${thread}"
+ show-file-path
+ ?show-ported-comment="${thread.ported}"
+ ?show-comment-context="${this.showCommentContext}"
+ ?show-file-name="${isFirst}"
+ .messageId="${this.messageId}"
+ ?should-scroll-into-view="${thread.rootId === this.scrollCommentId}"
+ @comment-thread-editing-changed="${() => {
+ this.requestUpdate();
+ }}"
+ ></gr-comment-thread>
+ `;
+ }
+
+ private renderAuthorChips() {
+ const authors = getCommentAuthors(this.getDisplayedThreads(), this.account);
+ if (authors.length === 0) return;
+ return html`<span class="author-text">From:</span>${authors.map(author =>
+ this.renderAccountChip(author)
+ )}`;
+ }
+
+ private renderAccountChip(account: AccountInfo) {
+ const selected = this.selectedAuthors.some(
+ a => a._account_id === account._account_id
+ );
+ return html`
+ <gr-account-label
+ .account="${account}"
+ @click="${this.handleAccountClicked}"
+ selectionChipStyle
+ ?selected="${selected}"
+ ></gr-account-label>
+ `;
+ }
+
+ private getCommentsDropdownValue() {
+ if (this.draftsOnly) return CommentTabState.DRAFTS;
+ if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
+ return CommentTabState.SHOW_ALL;
+ }
+
+ private getSortDropdownEntries() {
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)})`,
+ // private, but visible for testing
+ getCommentsDropdownEntries() {
+ const items: DropdownItem[] = [];
+ const threads = this.getAllThreads();
+ items.push({
+ text: `Unresolved (${threads.filter(isUnresolved).length})`,
+ value: CommentTabState.UNRESOLVED,
+ });
+ if (this.account) {
+ items.push({
+ text: `Drafts (${threads.filter(isDraftThread).length})`,
value: CommentTabState.DRAFTS,
});
+ }
+ items.push({
+ text: `All (${threads.length})`,
+ value: CommentTabState.SHOW_ALL,
+ });
return items;
}
- getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) {
- return getCommentAuthors(threads, account);
- }
-
- handleAccountClicked(e: MouseEvent) {
+ private 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];
+ const predicate = (a: AccountInfo) => a._account_id === account._account_id;
+ const found = this.selectedAuthors.find(predicate);
+ if (found) {
+ this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a));
+ } else {
+ this.selectedAuthors = [...this.selectedAuthors, account];
+ }
}
- isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
- return selectedAuthors.some(a => a._account_id === author._account_id);
- }
-
- computeShouldScrollIntoView(
- comments: UIComment[],
- scrollCommentId?: UrlEncodedCommentId
- ) {
- const comment = comments?.[0];
- if (!comment) return false;
- return computeId(comment) === scrollCommentId;
- }
-
- handleSortDropdownValueChange(e: CustomEvent) {
- this.sortDropdownValue = e.detail.value;
- /*
- * Ideally we would have updateSortedThreads observe on sortDropdownValue
- * but the method triggered re-render only when the length of threads
- * changes, hence keep the explicit resortThreads method
- */
- this.resortThreads(this.threads);
- }
-
+ // private, but visible for testing
handleCommentsDropdownValueChange(e: CustomEvent) {
const value = e.detail.value;
- if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
- else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
- else this._handleAllComments();
- }
-
- _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
- if (
- this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
- !this.hideDropdown
- ) {
- // In case of equal timestamps we want futher ordering
- if (c1.updated && c2.updated && c1.updated !== c2.updated)
- return c1.updated > c2.updated ? -1 : 1;
+ switch (value) {
+ case CommentTabState.UNRESOLVED:
+ this.handleOnlyUnresolved();
+ break;
+ case CommentTabState.DRAFTS:
+ this.handleOnlyDrafts();
+ break;
+ default:
+ this.handleAllComments();
}
-
- if (c1.thread.path !== c2.thread.path) {
- // '/PATCHSET' will not come before '/COMMIT' when sorting
- // alphabetically so move it to the front explicitly
- if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return -1;
- }
- if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return 1;
- }
- return c1.thread.path.localeCompare(c2.thread.path);
- }
-
- // Patchset comments have no line/range associated with them
- if (c1.thread.line !== c2.thread.line) {
- if (!c1.thread.line || !c2.thread.line) {
- // one of them is a file level comment, show first
- return c1.thread.line ? 1 : -1;
- }
- return c1.thread.line < c2.thread.line ? -1 : 1;
- }
-
- if (c1.thread.patchNum !== c2.thread.patchNum) {
- if (!c1.thread.patchNum) return 1;
- if (!c2.thread.patchNum) return -1;
- // Threads left on Base when comparing Base vs X have patchNum = X
- // and CommentSide = PARENT
- // Threads left on 'edit' have patchNum set as latestPatchNum
- return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
- }
-
- if (c2.unresolved !== c1.unresolved) {
- if (!c1.unresolved) return 1;
- if (!c2.unresolved) return -1;
- }
-
- if (c2.hasDraft !== c1.hasDraft) {
- if (!c1.hasDraft) return 1;
- if (!c2.hasDraft) return -1;
- }
-
- if (c2.updated !== c1.updated) {
- if (!c1.updated) return 1;
- if (!c2.updated) return -1;
- return c2.updated.getTime() - c1.updated.getTime();
- }
-
- if (c2.thread.rootId !== c1.thread.rootId) {
- if (!c1.thread.rootId) return 1;
- if (!c2.thread.rootId) return -1;
- return c1.thread.rootId.localeCompare(c2.thread.rootId);
- }
-
- return 0;
- }
-
- resortThreads(threads: CommentThread[]) {
- const threadsWithInfo = threads.map(thread =>
- this._getThreadWithStatusInfo(thread)
- );
- this._sortedThreads = threadsWithInfo
- .sort((t1, t2) => this._compareThreads(t1, t2))
- .map(threadInfo => threadInfo.thread);
}
/**
- * Observer on threads and update _sortedThreads when needed.
- * Order as follows:
- * - Patchset level threads (descending based on patchset number)
- * - unresolved
- * - comments with drafts
- * - comments without drafts
- * - resolved
- * - comments with drafts
- * - comments without drafts
- * - File name
- * - Line number
- * - Unresolved (descending based on patchset number)
- * - comments with drafts
- * - comments without drafts
- * - Resolved (descending based on patchset number)
- * - comments with drafts
- * - comments without drafts
- *
- * @param threads
- * @param spliceRecord
+ * Returns all threads that the list may show.
*/
- @observe('threads', 'threads.splices')
- _updateSortedThreads(
- threads: CommentThread[],
- _: PolymerSpliceChange<CommentThread[]>
- ) {
- if (!threads || threads.length === 0) {
- this._sortedThreads = [];
- this._displayedThreads = [];
- return;
- }
- // We only want to sort on thread additions / removals to avoid
- // re-rendering on modifications (add new reply / edit draft etc.).
- // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
- // TODO(TS): We have removed a buggy check of the splices here. A splice
- // with addedCount > 0 or removed.length > 0 should also cause re-sorting
- // and re-rendering, but apparently spliceRecord is always undefined for
- // whatever reason.
- // If there is an unsaved draftThread which is supposed to be replaced with
- // a saved draftThread then resort all threads
- const unsavedThread = this._sortedThreads.some(thread =>
- thread.rootId?.includes('draft__')
- );
- if (this._sortedThreads.length === threads.length && !unsavedThread) {
- // Instead of replacing the _sortedThreads which will trigger a re-render,
- // we override all threads inside of it.
- for (const thread of threads) {
- const idxInSortedThreads = this._sortedThreads.findIndex(
- t => t.rootId === thread.rootId
- );
- this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
- }
- return;
- }
-
- this.resortThreads(threads);
- }
-
- _computeDisplayedThreads(
- sortedThreadsRecord?: PolymerDeepPropertyChange<
- CommentThread[],
- CommentThread[]
- >,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
- return sortedThreadsRecord.base.filter(t =>
- this._shouldShowThread(
- t,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
+ // private, but visible for testing
+ getAllThreads() {
+ return this.threads.filter(
+ t =>
+ !this.onlyShowRobotCommentsWithHumanReply ||
+ !isRobotThread(t) ||
+ hasHumanReply(t)
);
}
- _isFirstThreadWithFileName(
- displayedThreads: CommentThread[],
- thread: CommentThread,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- const threads = displayedThreads.filter(t =>
- this._shouldShowThread(
- t,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
- );
- const index = threads.findIndex(t => t.rootId === thread.rootId);
- if (index === -1) {
- return false;
- }
- return index === 0 || threads[index - 1].path !== threads[index].path;
+ /**
+ * Returns all threads that are currently shown in the list, respecting the
+ * currently selected filter.
+ */
+ // private, but visible for testing
+ getDisplayedThreads() {
+ const byTimestamp =
+ this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
+ !this.hideDropdown;
+ return this.getAllThreads()
+ .sort((t1, t2) => compareThreads(t1, t2, byTimestamp))
+ .filter(t => this.shouldShowThread(t));
}
- _shouldRenderSeparator(
- displayedThreads: CommentThread[],
- thread: CommentThread,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- const threads = displayedThreads.filter(t =>
- this._shouldShowThread(
- t,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
- );
- const index = threads.findIndex(t => t.rootId === thread.rootId);
- if (index === -1) {
- return false;
- }
- return (
- index > 0 &&
- this._isFirstThreadWithFileName(
- displayedThreads,
- thread,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
+ private isASelectedAuthor(account?: AccountInfo) {
+ if (!account) return false;
+ return this.selectedAuthors.some(
+ author => account._account_id === author._account_id
);
}
- _shouldShowThread(
- thread: CommentThread,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- if (
- [
- thread,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors,
- ].includes(undefined)
- ) {
- return false;
+ private shouldShowThread(thread: CommentThread) {
+ // Never make a thread disappear while the user is editing it.
+ assertIsDefined(thread.rootId, 'thread.rootId');
+ const el = this.queryThreadElement(thread.rootId);
+ if (el?.editing) return true;
+
+ if (this.selectedAuthors.length > 0) {
+ const hasACommentFromASelectedAuthor = thread.comments.some(c =>
+ this.isASelectedAuthor(c.author)
+ );
+ if (!hasACommentFromASelectedAuthor) return false;
}
- if (selectedAuthors!.length) {
- if (
- !thread.comments.some(
- c =>
- c.author &&
- selectedAuthors!.some(
- author => c.author!._account_id === author._account_id
- )
- )
- ) {
- return false;
- }
+ // This is probably redundant, because getAllThreads() filters this out.
+ if (this.onlyShowRobotCommentsWithHumanReply) {
+ if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
}
- if (
- !draftsOnly &&
- !unresolvedOnly &&
- !onlyShowRobotCommentsWithHumanReply
- ) {
- return true;
- }
+ if (this.draftsOnly && !isDraftThread(thread)) return false;
+ if (this.unresolvedOnly && !isUnresolved(thread)) return false;
- 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;
+ return true;
}
- _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
- const comments = thread.comments;
- const lastComment = comments.length
- ? comments[comments.length - 1]
- : undefined;
- const hasRobotComment = isRobotThread(thread);
- const hasHumanReplyToRobotComment =
- hasRobotComment && hasHumanReply(thread);
- let updated = undefined;
- if (lastComment) {
- if (isDraft(lastComment)) updated = lastComment.__date;
- if (lastComment.updated) updated = parseDate(lastComment.updated);
- }
-
- return {
- thread,
- hasRobotComment,
- hasHumanReplyToRobotComment,
- unresolved: !!lastComment && !!lastComment.unresolved,
- isEditing: isDraft(lastComment) && !!lastComment.__editing,
- hasDraft: !!lastComment && isDraft(lastComment),
- updated,
- };
- }
-
- _isOnParent(side?: CommentSide) {
- // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
- // classified as parent??
- return !!side;
- }
-
- _handleOnlyUnresolved() {
+ private handleOnlyUnresolved() {
this.unresolvedOnly = true;
- this._draftsOnly = false;
+ this.draftsOnly = false;
}
- _handleOnlyDrafts() {
- this._draftsOnly = true;
+ private handleOnlyDrafts() {
+ this.draftsOnly = true;
this.unresolvedOnly = false;
}
- _handleAllComments() {
- this._draftsOnly = false;
+ private handleAllComments() {
+ this.draftsOnly = false;
this.unresolvedOnly = false;
}
- _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
- return !draftsOnly && !unresolvedOnly;
- }
-
- _countUnresolved(threads?: CommentThread[]) {
- return (
- this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
- .length ?? 0
- );
- }
-
- _countAllThreads(threads?: CommentThread[]) {
- return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
- }
-
- _countDrafts(threads?: CommentThread[]) {
- return (
- this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
- .length ?? 0
- );
- }
-
- filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
- return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
- }
-
- _commentTabStateChange(
- newValue?: CommentTabState,
- oldValue?: CommentTabState
- ) {
- if (!newValue || newValue === oldValue) return;
- let focusTo: string | undefined;
- switch (newValue) {
- case CommentTabState.UNRESOLVED:
- this._handleOnlyUnresolved();
- // input is null because it's not rendered yet.
- focusTo = '#unresolvedRadio';
- break;
- case CommentTabState.DRAFTS:
- this._handleOnlyDrafts();
- focusTo = '#draftsRadio';
- break;
- case CommentTabState.SHOW_ALL:
- this._handleAllComments();
- focusTo = '#allRadio';
- break;
- default:
- assertNever(newValue, 'Unsupported preferred state');
- }
- const selector = focusTo;
- window.setTimeout(() => {
- const input = this.shadowRoot?.querySelector<HTMLInputElement>(selector);
- input?.focus();
- }, 0);
+ private queryThreadElement(rootId: string): GrCommentThread | undefined {
+ const els = [...(this.threadElements ?? [])] as GrCommentThread[];
+ return els.find(el => el.rootId === rootId);
}
}
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
deleted file mode 100644
index 3eb28c9..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- #threads {
- display: block;
- }
- gr-comment-thread {
- display: block;
- margin-bottom: var(--spacing-m);
- }
- .header {
- align-items: center;
- background-color: var(--background-color-primary);
- border-bottom: 1px solid var(--border-color);
- border-top: 1px solid var(--border-color);
- display: flex;
- justify-content: left;
- padding: var(--spacing-s) var(--spacing-l);
- }
- .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
- .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
- .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
- display: block;
- }
- .thread-separator {
- border-top: 1px solid var(--border-color);
- margin-top: var(--spacing-xl);
- }
- .show-resolved-comments {
- box-shadow: none;
- padding-left: var(--spacing-m);
- }
- .partypopper{
- margin-right: var(--spacing-s);
- }
- gr-dropdown-list {
- --trigger-style-text-color: var(--primary-text-color);
- --trigger-style-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="[[!hideDropdown]]">
- <div class="header">
- <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>
- <template is="dom-if" if="[[_displayedThreads.length]]">
- <span class="author-text">From:</span>
- <template is="dom-repeat" items="[[getCommentAuthors(_displayedThreads, account)]]">
- <gr-account-label
- account="[[item]]"
- on-click="handleAccountClicked"
- selectionChipStyle
- selected="[[isSelected(item, selectedAuthors)]]"
- > </gr-account-label>
- </template>
- </template>
- </div>
- </template>
- <div id="threads" part="threads">
- <template
- is="dom-if"
- if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
- >
- <div>
- <span>
- <template is="dom-if" if="[[_showPartyPopper(threads)]]">
- <span class="partypopper">\🎉</span>
- </template>
- [[_computeEmptyThreadsMessage(threads, _displayedThreads,
- unresolvedOnly)]]
- <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
- <gr-button
- class="show-resolved-comments"
- link
- on-click="_handleResolvedCommentsMessageClick">
- [[_computeResolvedCommentsMessage(threads, _displayedThreads,
- unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
- </gr-button>
- </template>
- </span>
- </div>
- </template>
- <template
- is="dom-repeat"
- items="[[_displayedThreads]]"
- as="thread"
- initial-count="10"
- target-framerate="60"
- >
- <template
- is="dom-if"
- if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
- >
- <div class="thread-separator"></div>
- </template>
- <gr-comment-thread
- show-file-path=""
- show-ported-comment="[[thread.ported]]"
- show-comment-context="[[showCommentContext]]"
- change-num="[[changeNum]]"
- comments="[[thread.comments]]"
- diff-side="[[thread.diffSide]]"
- show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
- project-name="[[change.project]]"
- is-on-parent="[[_isOnParent(thread.commentSide)]]"
- line-num="[[thread.line]]"
- patch-num="[[thread.patchNum]]"
- path="[[thread.path]]"
- root-id="{{thread.rootId}}"
- should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
- ></gr-comment-thread>
- </template>
- </div>
-`;
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
deleted file mode 100644
index aab5cee..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ /dev/null
@@ -1,673 +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 '../../../test/common-test-setup-karma.js';
-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';
-import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-thread-list');
-
-suite('gr-thread-list tests', () => {
- let element;
-
- function getVisibleThreads() {
- return [...dom(element.root)
- .querySelectorAll('gr-comment-thread')]
- .filter(e => e.style.display !== 'none');
- }
-
- setup(async () => {
- element = basicFixture.instantiate();
- element.changeNum = 123;
- element.change = {
- project: 'testRepo',
- };
- element.threads = [
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000001,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'ecf0b9fa_fe1a5f62',
- line: 5,
- updated: '1',
- message: 'test',
- unresolved: true,
- },
- {
- id: '503008e2_0ab203ee',
- path: '/COMMIT_MSG',
- line: 5,
- in_reply_to: 'ecf0b9fa_fe1a5f62',
- updated: '1',
- message: 'draft',
- unresolved: true,
- __draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 5,
- rootId: 'ecf0b9fa_fe1a5f62',
- updated: '1',
- },
- {
- comments: [
- {
- path: 'test.txt',
- author: {
- _account_id: 1000002,
- name: 'user',
- username: 'user',
- },
- patch_set: 3,
- id: '09a9fb0a_1484e6cf',
- side: 'PARENT',
- updated: '2',
- message: 'Some comment on another patchset.',
- unresolved: false,
- },
- ],
- patchNum: 3,
- path: 'test.txt',
- rootId: '09a9fb0a_1484e6cf',
- updated: '2',
- commentSide: 'PARENT',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000002,
- name: 'user',
- username: 'user',
- },
- patch_set: 2,
- id: '8caddf38_44770ec1',
- updated: '3',
- message: 'Another unresolved comment',
- unresolved: false,
- },
- ],
- patchNum: 2,
- path: '/COMMIT_MSG',
- rootId: '8caddf38_44770ec1',
- updated: '3',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000003,
- name: 'user',
- username: 'user',
- },
- patch_set: 2,
- id: 'scaddf38_44770ec1',
- line: 4,
- updated: '4',
- message: 'Yet another unresolved comment',
- unresolved: true,
- },
- ],
- patchNum: 2,
- path: '/COMMIT_MSG',
- line: 4,
- rootId: 'scaddf38_44770ec1',
- updated: '4',
- },
- {
- comments: [
- {
- id: 'zcf0b9fa_fe1a5f62',
- path: '/COMMIT_MSG',
- line: 6,
- updated: '5',
- message: 'resolved draft',
- unresolved: false,
- __draft: true,
- __draftID: '0.m683trwff69',
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 6,
- rootId: 'zcf0b9fa_fe1a5f62',
- updated: '5',
- },
- {
- comments: [
- {
- id: 'patchset_level_1',
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- updated: '6',
- message: 'patchset comment 1',
- unresolved: false,
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 2,
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- rootId: 'patchset_level_1',
- updated: '6',
- },
- {
- comments: [
- {
- id: 'patchset_level_2',
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- updated: '7',
- message: 'patchset comment 2',
- unresolved: false,
- __editing: false,
- patch_set: '3',
- },
- ],
- patchNum: 3,
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- rootId: 'patchset_level_2',
- updated: '7',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'rc1',
- line: 5,
- updated: '8',
- message: 'test',
- unresolved: true,
- robot_id: 'rc1',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 5,
- rootId: 'rc1',
- updated: '8',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'rc2',
- line: 7,
- updated: '9',
- message: 'test',
- unresolved: true,
- robot_id: 'rc2',
- },
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'c2_1',
- line: 5,
- updated: '10',
- message: 'test',
- unresolved: true,
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 7,
- rootId: 'rc2',
- updated: '10',
- },
- ];
-
- // use flush to render all (bypass initial-count set on dom-repeat)
- await flush();
- });
-
- test('draft dropdown item only appears when logged in', () => {
- element.loggedIn = false;
- flush();
- assert.equal(element.getCommentsDropdownEntires(element.threads,
- element.loggedIn).length, 2);
- element.loggedIn = true;
- flush();
- assert.equal(element.getCommentsDropdownEntires(element.threads,
- element.loggedIn).length, 3);
- });
-
- test('show all threads by default', () => {
- assert.equal(dom(element.root)
- .querySelectorAll('gr-comment-thread').length, element.threads.length);
- assert.equal(getVisibleThreads().length, element.threads.length);
- });
-
- test('show unresolved threads if unresolvedOnly is set', async () => {
- element.unresolvedOnly = true;
- await flush();
- const unresolvedThreads = element.threads.filter(t => t.comments.some(
- c => c.unresolved
- ));
- assert.equal(getVisibleThreads().length, unresolvedThreads.length);
- });
-
- 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, element.selectedAuthors),
- true);
- element.unresolvedOnly = true;
- assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
- element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
- element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
- false);
- });
-
- test('onlyShowRobotCommentsWithHumanReply ', () => {
- element.onlyShowRobotCommentsWithHumanReply = true;
- flush();
- assert.equal(
- getVisibleThreads().length,
- element.threads.length - 1);
- assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
- });
-
- 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}};
-
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- // assigning values to properties such that t2 should come first
- t1.patchNum = 1;
- t2.patchNum = 2;
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('file path is compared lexicographically', () => {
- const t1 = {thread: {path: 'a.txt'}};
- const t2 = {thread: {path: 'b.txt'}};
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- t1.patchNum = 1;
- t2.patchNum = 2;
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('patchset comments sorted by reverse patchset', () => {
- const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 1}};
- const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 2}};
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
-
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
-
- test('patchset comments with same patchset picks unresolved first', () => {
- const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 1}, unresolved: true};
- const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 1}, unresolved: false};
- t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('file level comment before line', () => {
- const t1 = {thread: {path: 'a.txt', line: 2}};
- const t2 = {thread: {path: 'a.txt'}};
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
-
- // give preference to t1 in unresolved/draft properties
- t1.unresolved = t1.hasDraft = true;
- t2.unresolved = t2.unresolved = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
-
- test('comments sorted by line', () => {
- const t1 = {thread: {path: 'a.txt', line: 2}};
- const t2 = {thread: {path: 'a.txt', line: 3}};
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('comments on same line sorted by reverse patchset', () => {
- const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
- const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
-
- // give preference to t1 in unresolved/draft properties
- t1.unresolved = t1.hasDraft = true;
- t2.unresolved = t2.unresolved = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
-
- test('comments on same line & patchset sorted by unresolved first',
- () => {
- const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: true};
- const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: false};
- t1.patchNum = t2.patchNum = 1;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- t2.hasDraft = true;
- t1.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('comments on same line & patchset & unresolved sorted by draft',
- () => {
- const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: true, hasDraft: false};
- const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: true, hasDraft: true};
- t1.patchNum = t2.patchNum = 1;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
- });
-
- test('_computeSortedThreads', () => {
- element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
- assert.equal(element._sortedThreads.length, 9);
- const expectedSortedRootIds = [
- 'patchset_level_2', // Posted on Patchset 3
- 'patchset_level_1', // Posted on Patchset 2
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- 'rc2', // Line 7 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- 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', () => {
- element.account = createAccountDetailWithId(1);
- flush();
- const chips = Array.from(queryAll(element, 'gr-account-label'));
- const authors = chips.map(
- chip => accountOrGroupKey(chip.account))
- .sort();
- assert.deepEqual(authors, [1, 1000000, 1000001, 1000002, 1000003]);
- assert.equal(element.threads.length, 9);
- assert.equal(element._displayedThreads.length, 9);
-
- // accountId 1000001
- const chip = chips.find(chip => chip.account._account_id === 1000001);
-
- tap(chip);
- 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(chip); // tapping again resets
- flush();
- assert.equal(element.threads.length, 9);
- assert.equal(element._displayedThreads.length, 9);
- });
-
- test('tapping multiple author chips', () => {
- element.account = createAccountDetailWithId(1);
- flush();
- const chips = Array.from(queryAll(element, 'gr-account-label'));
-
- tap(chips.find(chip => chip.account._account_id === 1000001));
- tap(chips.find(chip => chip.account._account_id === 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;
- const index = element.threads.findIndex(t => t.rootId === 'rc2');
- element.threads.splice(index, 1);
- element.threads = [...element.threads]; // trigger observers
- flush();
- assert.equal(element._sortedThreads.length, 8);
- const expectedSortedRootIds = [
- 'patchset_level_2',
- 'patchset_level_1',
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- 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];
- }
- const modifiedThreads = [...element.threads];
- modifiedThreads[5] = {...modifiedThreads[5]};
- modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
- ...modifiedThreads[5].comments[0],
- unresolved: false,
- }];
- element.threads = modifiedThreads;
- assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
-
- // exact same order as in _computeSortedThreads
- const expectedSortedRootIds = [
- 'patchset_level_2',
- 'patchset_level_1',
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- 'rc2', // Line 7 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- test('reset sortedThreads when threads set to undefiend', () => {
- element.threads = undefined;
- assert.deepEqual(element._sortedThreads, []);
- });
-
- 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 = [];
- element.threads = modifiedThreads;
- assert.deepEqual(currentSortedThreads, element._sortedThreads);
-
- // exact same order as in _computeSortedThreads
- const expectedSortedRootIds = [
- 'patchset_level_2',
- 'patchset_level_1',
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- 'rc2', // Line 7 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- test('show all comments', () => {
- element.handleCommentsDropdownValueChange({detail: {
- value: CommentTabState.SHOW_ALL}});
- flush();
- assert.equal(getVisibleThreads().length, 9);
- });
-
- 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', () => {
- element.handleCommentsDropdownValueChange({detail: {
- value: CommentTabState.DRAFTS}});
- flush();
- assert.equal(getVisibleThreads().length, 2);
- });
-
- suite('hideDropdown', () => {
- setup(async () => {
- element.hideDropdown = true;
- await flush();
- });
-
- test('toggle buttons are hidden', () => {
- assert.equal(element.shadowRoot.querySelector('.header').style.display,
- 'none');
- });
- });
-
- suite('empty thread', () => {
- setup(async () => {
- element.threads = [];
- await flush();
- });
-
- test('default empty message should show', () => {
- assert.isTrue(
- element.shadowRoot.querySelector('#threads').textContent.trim()
- .includes('No comments'));
- });
- });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
new file mode 100644
index 0000000..f6b9a81
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -0,0 +1,516 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import './gr-thread-list';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {
+ compareThreads,
+ GrThreadList,
+ __testOnly_SortDropdownState,
+} from './gr-thread-list';
+import {queryAll} from '../../../test/test-utils';
+import {accountOrGroupKey} from '../../../utils/account-util';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+ createAccountDetailWithId,
+ createParsedChange,
+ createThread,
+} from '../../../test/test-data-generators';
+import {
+ AccountId,
+ NumericChangeId,
+ PatchSetNum,
+ Timestamp,
+} from '../../../api/rest-api';
+import {RobotId, UrlEncodedCommentId} from '../../../types/common';
+import {CommentThread} from '../../../utils/comment-util';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+
+const basicFixture = fixtureFromElement('gr-thread-list');
+
+suite('gr-thread-list tests', () => {
+ let element: GrThreadList;
+
+ setup(async () => {
+ element = basicFixture.instantiate();
+ element.changeNum = 123 as NumericChangeId;
+ element.change = createParsedChange();
+ element.account = createAccountDetailWithId();
+ element.threads = [
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000001 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2015-12-01 15:15:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ {
+ id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+ path: '/COMMIT_MSG',
+ line: 5,
+ in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ updated: '2015-12-01 15:16:15.000000000' as Timestamp,
+ message: 'draft',
+ unresolved: true,
+ __draft: true,
+ patch_set: '2' as PatchSetNum,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 5,
+ rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: 'test.txt',
+ author: {
+ _account_id: 1000002 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 3 as PatchSetNum,
+ id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ updated: '2015-12-02 15:16:15.000000000' as Timestamp,
+ message: 'Some comment on another patchset.',
+ unresolved: false,
+ },
+ ],
+ patchNum: 3 as PatchSetNum,
+ path: 'test.txt',
+ rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000002 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 2 as PatchSetNum,
+ id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+ updated: '2015-12-03 15:16:15.000000000' as Timestamp,
+ message: 'Another unresolved comment',
+ unresolved: false,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000003 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 2 as PatchSetNum,
+ id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ line: 4,
+ updated: '2015-12-04 15:16:15.000000000' as Timestamp,
+ message: 'Yet another unresolved comment',
+ unresolved: true,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 4,
+ rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ path: '/COMMIT_MSG',
+ line: 6,
+ updated: '2015-12-05 15:16:15.000000000' as Timestamp,
+ message: 'resolved draft',
+ unresolved: false,
+ __draft: true,
+ patch_set: '2' as PatchSetNum,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 6,
+ rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ id: 'patchset_level_1' as UrlEncodedCommentId,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ updated: '2015-12-06 15:16:15.000000000' as Timestamp,
+ message: 'patchset comment 1',
+ unresolved: false,
+ patch_set: '2' as PatchSetNum,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ rootId: 'patchset_level_1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ id: 'patchset_level_2' as UrlEncodedCommentId,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ updated: '2015-12-07 15:16:15.000000000' as Timestamp,
+ message: 'patchset comment 2',
+ unresolved: false,
+ patch_set: '3' as PatchSetNum,
+ },
+ ],
+ patchNum: 3 as PatchSetNum,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ rootId: 'patchset_level_2' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'rc1' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2015-12-08 15:16:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ robot_id: 'rc1' as RobotId,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 5,
+ rootId: 'rc1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'rc2' as UrlEncodedCommentId,
+ line: 7,
+ updated: '2015-12-09 15:16:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ robot_id: 'rc2' as RobotId,
+ },
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'c2_1' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2015-12-10 15:16:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 7,
+ rootId: 'rc2' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ ];
+ await element.updateComplete;
+ });
+
+ suite('sort threads', () => {
+ test('sort all threads', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+ assert.equal(element.getDisplayedThreads().length, 9);
+ const expected: UrlEncodedCommentId[] = [
+ 'patchset_level_2' as UrlEncodedCommentId, // Posted on Patchset 3
+ 'patchset_level_1' as UrlEncodedCommentId, // Posted on Patchset 2
+ '8caddf38_44770ec1' as UrlEncodedCommentId, // File level on COMMIT_MSG
+ 'scaddf38_44770ec1' as UrlEncodedCommentId, // Line 4 on COMMIT_MSG
+ 'rc1' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE newer
+ 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE older
+ 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 6 on COMMIT_MSG
+ 'rc2' as UrlEncodedCommentId, // Line 7 on COMMIT_MSG
+ '09a9fb0a_1484e6cf' as UrlEncodedCommentId, // File level on test.txt
+ ];
+ const actual = element.getDisplayedThreads().map(t => t.rootId);
+ assert.sameOrderedMembers(actual, expected);
+ });
+
+ test('sort all threads by timestamp', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+ assert.equal(element.getDisplayedThreads().length, 9);
+ const expected: UrlEncodedCommentId[] = [
+ 'rc2' as UrlEncodedCommentId,
+ 'rc1' as UrlEncodedCommentId,
+ 'patchset_level_2' as UrlEncodedCommentId,
+ 'patchset_level_1' as UrlEncodedCommentId,
+ 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ '8caddf38_44770ec1' as UrlEncodedCommentId,
+ '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ ];
+ const actual = element.getDisplayedThreads().map(t => t.rootId);
+ assert.sameOrderedMembers(actual, expected);
+ });
+ });
+
+ test('renders', async () => {
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="header">
+ <span class="sort-text">Sort By:</span>
+ <gr-dropdown-list id="sortDropdown"></gr-dropdown-list>
+ <span class="separator"></span>
+ <span class="filter-text">Filter By:</span>
+ <gr-dropdown-list id="filterDropdown"></gr-dropdown-list>
+ <span class="author-text">From:</span>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ </div>
+ <div id="threads" part="threads">
+ <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+ </div>
+ `);
+ });
+
+ test('renders empty', async () => {
+ element.threads = [];
+ await element.updateComplete;
+ expect(queryAndAssert(element, 'div#threads')).dom.to.equal(`
+ <div id="threads" part="threads">
+ <div><span>No comments</span></div>
+ </div>
+ `);
+ });
+
+ test('tapping single author chips', async () => {
+ element.account = createAccountDetailWithId(1);
+ await element.updateComplete;
+ const chips = Array.from(
+ queryAll<GrAccountLabel>(element, 'gr-account-label')
+ );
+ const authors = chips.map(chip => accountOrGroupKey(chip.account!)).sort();
+ assert.deepEqual(authors, [
+ 1 as AccountId,
+ 1000000 as AccountId,
+ 1000001 as AccountId,
+ 1000002 as AccountId,
+ 1000003 as AccountId,
+ ]);
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 9);
+
+ const chip = chips.find(chip => chip.account!._account_id === 1000001);
+ tap(chip!);
+ await element.updateComplete;
+
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 1);
+ assert.equal(
+ element.getDisplayedThreads()[0].comments[0].author?._account_id,
+ 1000001 as AccountId
+ );
+
+ tap(chip!);
+ await element.updateComplete;
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 9);
+ });
+
+ test('tapping multiple author chips', async () => {
+ element.account = createAccountDetailWithId(1);
+ await element.updateComplete;
+ const chips = Array.from(
+ queryAll<GrAccountLabel>(element, 'gr-account-label')
+ );
+
+ tap(chips.find(chip => chip.account?._account_id === 1000001)!);
+ tap(chips.find(chip => chip.account?._account_id === 1000002)!);
+ await element.updateComplete;
+
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 3);
+ assert.equal(
+ element.getDisplayedThreads()[0].comments[0].author?._account_id,
+ 1000002 as AccountId
+ );
+ assert.equal(
+ element.getDisplayedThreads()[1].comments[0].author?._account_id,
+ 1000002 as AccountId
+ );
+ assert.equal(
+ element.getDisplayedThreads()[2].comments[0].author?._account_id,
+ 1000001 as AccountId
+ );
+ });
+
+ test('show all comments', async () => {
+ const event = new CustomEvent('value-changed', {
+ detail: {value: CommentTabState.SHOW_ALL},
+ });
+ element.handleCommentsDropdownValueChange(event);
+ await element.updateComplete;
+ assert.equal(element.getDisplayedThreads().length, 9);
+ });
+
+ test('unresolved shows all unresolved comments', async () => {
+ const event = new CustomEvent('value-changed', {
+ detail: {value: CommentTabState.UNRESOLVED},
+ });
+ element.handleCommentsDropdownValueChange(event);
+ await element.updateComplete;
+ assert.equal(element.getDisplayedThreads().length, 4);
+ });
+
+ test('toggle drafts only shows threads with draft comments', async () => {
+ const event = new CustomEvent('value-changed', {
+ detail: {value: CommentTabState.DRAFTS},
+ });
+ element.handleCommentsDropdownValueChange(event);
+ await element.updateComplete;
+ assert.equal(element.getDisplayedThreads().length, 2);
+ });
+
+ suite('hideDropdown', () => {
+ test('header hidden for hideDropdown=true', async () => {
+ element.hideDropdown = true;
+ await element.updateComplete;
+ assert.isUndefined(query(element, '.header'));
+ });
+
+ test('header shown for hideDropdown=false', async () => {
+ element.hideDropdown = false;
+ await element.updateComplete;
+ assert.isDefined(query(element, '.header'));
+ });
+ });
+
+ suite('empty thread', () => {
+ setup(async () => {
+ element.threads = [];
+ await element.updateComplete;
+ });
+
+ test('default empty message should show', () => {
+ const threadsEl = queryAndAssert(element, '#threads');
+ assert.isTrue(threadsEl.textContent?.trim().includes('No comments'));
+ });
+ });
+});
+
+suite('compareThreads', () => {
+ let t1: CommentThread;
+ let t2: CommentThread;
+
+ const sortPredicate = (thread1: CommentThread, thread2: CommentThread) =>
+ compareThreads(thread1, thread2);
+
+ const checkOrder = (expected: CommentThread[]) => {
+ assert.sameOrderedMembers([t1, t2].sort(sortPredicate), expected);
+ assert.sameOrderedMembers([t2, t1].sort(sortPredicate), expected);
+ };
+
+ setup(() => {
+ t1 = createThread({});
+ t2 = createThread({});
+ });
+
+ test('patchset-level before file comments', () => {
+ t1.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+ t2.path = SpecialFilePath.COMMIT_MESSAGE;
+ checkOrder([t1, t2]);
+ });
+
+ test('paths lexicographically', () => {
+ t1.path = 'a.txt';
+ t2.path = 'b.txt';
+ checkOrder([t1, t2]);
+ });
+
+ test('patchsets in reverse order', () => {
+ t1.patchNum = 2 as PatchSetNum;
+ t2.patchNum = 3 as PatchSetNum;
+ checkOrder([t2, t1]);
+ });
+
+ test('file level comment before line', () => {
+ t1.line = 123;
+ t2.line = 'FILE';
+ checkOrder([t2, t1]);
+ });
+
+ test('comments sorted by line', () => {
+ t1.line = 123;
+ t2.line = 321;
+ checkOrder([t1, t2]);
+ });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index b213fa6..74d0e30 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -28,7 +28,7 @@
@property({type: Object})
eventTarget: HTMLElement | null = null;
- private checksService = getAppContext().checksService;
+ private checksModel = getAppContext().checksModel;
override connectedCallback() {
super.connectedCallback();
@@ -80,7 +80,7 @@
handleClick(e: Event) {
e.stopPropagation();
- this.checksService.triggerAction(this.action);
+ this.checksModel.triggerAction(this.action);
}
}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 342f54b..de2099e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -32,14 +32,7 @@
Tag,
} from '../../api/checks';
import {sharedStyles} from '../../styles/shared-styles';
-import {
- CheckRun,
- checksSelectedPatchsetNumber$,
- RunResult,
- someProvidersAreLoadingSelected$,
- topLevelActionsSelected$,
- topLevelLinksSelected$,
-} from '../../services/checks/checks-model';
+import {CheckRun, RunResult} from '../../services/checks/checks-model';
import {
allResults,
firstPrimaryLink,
@@ -62,9 +55,7 @@
LabelNameToInfoMap,
PatchSetNumber,
} from '../../types/common';
-import {labels$, latestPatchNum$} from '../../services/change/change-model';
import {getAppContext} from '../../services/app-context';
-import {repoConfig$} from '../../services/config/config-model';
import {spinnerStyles} from '../../styles/gr-spinner-styles';
import {
getLabelStatus,
@@ -96,11 +87,13 @@
@state()
labels?: LabelNameToInfoMap;
- private checksService = getAppContext().checksService;
+ private changeModel = getAppContext().changeModel;
+
+ private checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, labels$, x => (this.labels = x));
+ subscribe(this, this.changeModel.labels$, x => (this.labels = x));
}
static override get styles() {
@@ -494,7 +487,7 @@
}
private handleAction(e: CustomEvent<Action>) {
- this.checksService.triggerAction(e.detail);
+ this.checksModel.triggerAction(e.detail);
}
private renderAction(action?: Action) {
@@ -538,7 +531,9 @@
@state()
repoConfig?: ConfigInfo;
- private changeService = getAppContext().changeService;
+ private changeModel = getAppContext().changeModel;
+
+ private configModel = getAppContext().configModel;
static override get styles() {
return [
@@ -563,7 +558,7 @@
constructor() {
super();
- subscribe(this, repoConfig$, x => (this.repoConfig = x));
+ subscribe(this, this.configModel.repoConfig$, x => (this.repoConfig = x));
}
override render() {
@@ -624,7 +619,7 @@
const end = pointer?.range?.end_line;
if (start) rangeText += `#${start}`;
if (end && start !== end) rangeText += `-${end}`;
- const change = this.changeService.getChange();
+ const change = this.changeModel.getChange();
assertIsDefined(change);
const path = pointer.path;
const patchset = this.result?.patchset as PatchSetNumber | undefined;
@@ -732,21 +727,35 @@
*/
private isSectionExpandedByUser = new Map<Category, boolean>();
- private readonly checksService = getAppContext().checksService;
+ private readonly changeModel = getAppContext().changeModel;
+
+ private readonly checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, topLevelActionsSelected$, x => (this.actions = x));
- subscribe(this, topLevelLinksSelected$, x => (this.links = x));
subscribe(
this,
- checksSelectedPatchsetNumber$,
+ this.checksModel.topLevelActionsSelected$,
+ x => (this.actions = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.topLevelLinksSelected$,
+ x => (this.links = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.checksSelectedPatchsetNumber$,
x => (this.checksPatchsetNumber = x)
);
- subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
subscribe(
this,
- someProvidersAreLoadingSelected$,
+ this.changeModel.latestPatchNum$,
+ x => (this.latestPatchsetNumber = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.someProvidersAreLoadingSelected$,
x => (this.someProvidersAreLoading = x)
);
}
@@ -1100,7 +1109,7 @@
}
private handleAction(e: CustomEvent<Action>) {
- this.checksService.triggerAction(e.detail);
+ this.checksModel.triggerAction(e.detail);
}
private renderAction(action?: Action) {
@@ -1111,11 +1120,11 @@
private onPatchsetSelected(e: CustomEvent<{value: string}>) {
const patchset = Number(e.detail.value);
check(!isNaN(patchset), 'selected patchset must be a number');
- this.checksService.setPatchset(patchset as PatchSetNumber);
+ this.checksModel.setPatchset(patchset as PatchSetNumber);
}
private goToLatestPatchset() {
- this.checksService.setPatchset(undefined);
+ this.checksModel.setPatchset(undefined);
}
private createPatchsetDropdownItems() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 474d2f2..20041de 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -33,11 +33,11 @@
worstCategory,
} from '../../services/checks/checks-util';
import {
- allRunsSelectedPatchset$,
CheckRun,
ChecksPatchset,
ErrorMessages,
- errorMessagesLatest$,
+} from '../../services/checks/checks-model';
+import {
fakeActions,
fakeLinks,
fakeRun0,
@@ -45,9 +45,7 @@
fakeRun2,
fakeRun3,
fakeRun4Att,
- loginCallbackLatest$,
- updateStateSetResults,
-} from '../../services/checks/checks-model';
+} from '../../services/checks/checks-fakes';
import {assertIsDefined} from '../../utils/common-util';
import {modifierPressed, whenVisible} from '../../utils/dom-util';
import {
@@ -391,13 +389,25 @@
private flagService = getAppContext().flagsService;
- private checksService = getAppContext().checksService;
+ private checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
- subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
- subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
+ subscribe(
+ this,
+ this.checksModel.allRunsSelectedPatchset$,
+ x => (this.runs = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.errorMessagesLatest$,
+ x => (this.errorMessages = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.loginCallbackLatest$,
+ x => (this.loginCallback = x)
+ );
}
static override get styles() {
@@ -619,7 +629,7 @@
link
?disabled=${runButtonDisabled}
@click="${() => {
- actions.forEach(action => this.checksService.triggerAction(action));
+ actions.forEach(action => this.checksModel.triggerAction(action));
}}"
>Run Selected</gr-button
>
@@ -659,25 +669,79 @@
}
none() {
- updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
+ this.checksModel.updateStateSetResults(
+ 'f0',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f1',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f2',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f3',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f4',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
}
all() {
- updateStateSetResults(
+ this.checksModel.updateStateSetResults(
'f0',
[fakeRun0],
fakeActions,
fakeLinks,
ChecksPatchset.LATEST
);
- updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f4', fakeRun4Att, [], [], ChecksPatchset.LATEST);
+ this.checksModel.updateStateSetResults(
+ 'f1',
+ [fakeRun1],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f2',
+ [fakeRun2],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f3',
+ [fakeRun3],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f4',
+ fakeRun4Att,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
}
toggle(
@@ -687,7 +751,7 @@
links: Link[] = []
) {
const newRuns = this.runs.includes(runs[0]) ? [] : runs;
- updateStateSetResults(
+ this.checksModel.updateStateSetResults(
plugin,
newRuns,
actions,
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index d1ccd11..a9c30c5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -17,16 +17,9 @@
import {LitElement, css, html, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {Action} from '../../api/checks';
-import {
- CheckResult,
- CheckRun,
- allResultsSelected$,
- checksSelectedPatchsetNumber$,
- allRunsSelectedPatchset$,
-} from '../../services/checks/checks-model';
+import {CheckResult, CheckRun} from '../../services/checks/checks-model';
import './gr-checks-runs';
import './gr-checks-results';
-import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
import {NumericChangeId, PatchSetNumber} from '../../types/common';
import {ActionTriggeredEvent} from '../../services/checks/checks-util';
import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
@@ -68,19 +61,33 @@
number | undefined
>();
- private readonly checksService = getAppContext().checksService;
+ private readonly changeModel = getAppContext().changeModel;
+
+ private readonly checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
- subscribe(this, allResultsSelected$, x => (this.results = x));
subscribe(
this,
- checksSelectedPatchsetNumber$,
+ this.checksModel.allRunsSelectedPatchset$,
+ x => (this.runs = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.allResultsSelected$,
+ x => (this.results = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.checksSelectedPatchsetNumber$,
x => (this.checksPatchsetNumber = x)
);
- subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
- subscribe(this, changeNum$, x => (this.changeNum = x));
+ subscribe(
+ this,
+ this.changeModel.latestPatchNum$,
+ x => (this.latestPatchsetNumber = x)
+ );
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
this.handleActionTriggered(e.detail.action, e.detail.run)
@@ -140,7 +147,7 @@
}
handleActionTriggered(action: Action, run?: CheckRun) {
- this.checksService.triggerAction(action, run);
+ this.checksModel.triggerAction(action, run);
}
handleRunSelected(e: RunSelectedEvent) {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 29c8eca..be36640 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-icons/gr-icons';
@@ -36,9 +37,6 @@
import {AuthType} from '../../../constants/constants';
import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
import {getAppContext} from '../../../services/app-context';
-import {serverConfig$} from '../../../services/config/config-model';
-import {myTopMenuItems$} from '../../../services/user/user-model';
-import {assertIsDefined} from '../../../utils/common-util';
type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
@@ -158,7 +156,9 @@
private readonly jsAPI = getAppContext().jsApiService;
- private readonly userService = getAppContext().userService;
+ private readonly userModel = getAppContext().userModel;
+
+ private readonly configModel = getAppContext().configModel;
private subscriptions: Subscription[] = [];
@@ -168,21 +168,21 @@
}
override connectedCallback() {
- // TODO(brohlfs): This just ensures that the userService is instantiated at
- // all. We need the service to manage the model, but we are not making any
- // direct calls. Will need to find a better solution to this problem ...
- assertIsDefined(this.userService);
-
super.connectedCallback();
this._loadAccount();
this.subscriptions.push(
- myTopMenuItems$.subscribe(items => {
- this._userLinks = items.map(this._createHeaderLink);
- })
+ this.userModel.preferences$
+ .pipe(
+ map(preferences => preferences?.my ?? []),
+ distinctUntilChanged()
+ )
+ .subscribe(items => {
+ this._userLinks = items.map(this._createHeaderLink);
+ })
);
this.subscriptions.push(
- serverConfig$.subscribe(config => {
+ this.configModel.serverConfig$.subscribe(config => {
if (!config) return;
this._retrieveFeedbackURL(config);
this._retrieveRegisterURL(config);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 027c976..2a35494 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -66,7 +66,7 @@
AppElementParams,
} from '../../gr-app-types';
import {LocationChangeEventDetail} from '../../../types/events';
-import {GerritView, updateState} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
import {firePageError} from '../../../utils/event-util';
import {addQuotesWhen} from '../../../utils/string-util';
import {windowLocationReload} from '../../../utils/dom-util';
@@ -311,6 +311,8 @@
private readonly reporting = getAppContext().reportingService;
+ private readonly routerModel = getAppContext().routerModel;
+
private readonly restApiService = getAppContext().restApiService;
private readonly flagsService = getAppContext().flagsService;
@@ -323,11 +325,11 @@
}
_setParams(params: AppElementParams | GenerateUrlParameters) {
- updateState(
- params.view,
- 'changeNum' in params ? params.changeNum : undefined,
- 'patchNum' in params ? params.patchNum ?? undefined : undefined
- );
+ this.routerModel.updateState({
+ view: params.view,
+ changeNum: 'changeNum' in params ? params.changeNum : undefined,
+ patchNum: 'patchNum' in params ? params.patchNum ?? undefined : undefined,
+ });
this._appElement().params = params;
}
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index e82ea89..9392cb9d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -24,6 +24,7 @@
BasePatchSetNum,
EditPatchSetNum,
PatchSetNum,
+ RobotCommentInfo,
RobotId,
RobotRunId,
Timestamp,
@@ -37,7 +38,6 @@
} from '../../../test/test-data-generators';
import {createDefaultDiffPrefs} from '../../../constants/constants';
import {DiffInfo} from '../../../types/diff';
-import {UIRobot} from '../../../utils/comment-util';
import {
CloseFixPreviewEventDetail,
EventType,
@@ -50,7 +50,7 @@
suite('gr-apply-fix-dialog tests', () => {
let element: GrApplyFixDialog;
- const ROBOT_COMMENT_WITH_TWO_FIXES: UIRobot = {
+ const ROBOT_COMMENT_WITH_TWO_FIXES: RobotCommentInfo = {
id: '1' as UrlEncodedCommentId,
updated: '2018-02-08 18:49:18.000000000' as Timestamp,
robot_id: 'robot_1' as RobotId,
@@ -62,7 +62,7 @@
],
};
- const ROBOT_COMMENT_WITH_ONE_FIX: UIRobot = {
+ const ROBOT_COMMENT_WITH_ONE_FIX: RobotCommentInfo = {
id: '2' as UrlEncodedCommentId,
updated: '2018-02-08 18:49:18.000000000' as Timestamp,
robot_id: 'robot_1' as RobotId,
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 32c732e..50399be 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -15,7 +15,6 @@
* limitations under the License.
*/
import {
- CommentBasics,
PatchRange,
PatchSetNum,
RobotCommentInfo,
@@ -31,7 +30,6 @@
CommentThread,
DraftInfo,
isUnresolved,
- UIComment,
createCommentThreads,
isInPatchRange,
isDraftThread,
@@ -41,7 +39,7 @@
addPath,
} from '../../../utils/comment-util';
import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
import {pluralize} from '../../../utils/string-util';
import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
@@ -114,7 +112,7 @@
* patchNum and basePatchNum properties to represent the range.
*/
getPaths(patchRange?: PatchRange): CommentMap {
- const responses: {[path: string]: UIComment[]}[] = [
+ const responses: {[path: string]: Comment[]}[] = [
this._comments,
this.drafts,
this._robotComments,
@@ -139,25 +137,11 @@
}
/**
- * Gets all the comments for a particular thread group. Used for refreshing
- * comments after the thread group has already been built.
- */
- getCommentsForThread(rootId: UrlEncodedCommentId) {
- const allThreads = this.getAllThreadsForChange();
- const threadMatch = allThreads.find(t => t.rootId === rootId);
-
- // In the event that a single draft comment was removed by the thread-list
- // and the diff view is updating comments, there will no longer be a thread
- // found. In this case, return null.
- return threadMatch ? threadMatch.comments : null;
- }
-
- /**
* Gets all the comments and robot comments for the given change.
*/
getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
const paths = this.getPaths();
- const publishedComments: {[path: string]: CommentBasics[]} = {};
+ const publishedComments: {[path: string]: CommentInfo[]} = {};
for (const path of Object.keys(paths)) {
publishedComments[path] = this.getAllCommentsForPath(
path,
@@ -191,8 +175,8 @@
path: string,
patchNum?: PatchSetNum,
includeDrafts?: boolean
- ): Comment[] {
- const comments: Comment[] = this._comments[path] || [];
+ ): CommentInfo[] {
+ const comments: CommentInfo[] = this._comments[path] || [];
const robotComments = this._robotComments[path] || [];
let allComments = comments.concat(robotComments);
if (includeDrafts) {
@@ -228,43 +212,18 @@
return allComments;
}
- cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | undefined) {
- return new ChangeComments(
- this._comments,
- this._robotComments,
- drafts,
- this._portedComments,
- this._portedDrafts
- );
- }
-
- cloneWithUpdatedPortedComments(
- portedComments?: PathToCommentsInfoMap,
- portedDrafts?: PathToCommentsInfoMap
- ) {
- return new ChangeComments(
- this._comments,
- this._robotComments,
- this._drafts,
- portedComments,
- portedDrafts
- );
- }
-
/**
* Get the drafts for a path and optional patch num.
*
* This will return a shallow copy of all drafts every time,
* so changes on any copy will not affect other copies.
*/
- getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
- let comments = this._drafts[path] || [];
+ getAllDraftsForPath(path: string, patchNum?: PatchSetNum): DraftInfo[] {
+ let drafts = this._drafts[path] || [];
if (patchNum) {
- comments = comments.filter(c => c.patch_set === patchNum);
+ drafts = drafts.filter(c => c.patch_set === patchNum);
}
- return comments.map(c => {
- return {...c, __draft: true};
- });
+ return drafts;
}
/**
@@ -272,7 +231,7 @@
*
* // TODO(taoalpha): maybe merge in *ForPath
*/
- getAllDraftsForFile(file: PatchSetFile): Comment[] {
+ getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
if (file.basePath) {
allDrafts = allDrafts.concat(
@@ -292,8 +251,8 @@
* @param projectConfig Optional project config object to
* include in the meta sub-object.
*/
- getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
- let comments: Comment[] = [];
+ getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
+ let comments: CommentInfo[] = [];
let drafts: DraftInfo[] = [];
let robotComments: RobotCommentInfo[] = [];
if (this._comments && this._comments[path]) {
@@ -306,17 +265,13 @@
robotComments = this._robotComments[path];
}
- drafts.forEach(d => {
- d.__draft = true;
- });
-
- return comments
- .concat(drafts)
- .concat(robotComments)
+ const all = comments.concat(drafts).concat(robotComments);
+ const final = all
.filter(c => isInPatchRange(c, patchRange))
.map(c => {
return {...c};
});
+ return final;
}
/**
@@ -367,7 +322,7 @@
// ported comments will involve comments that may not belong to the
// current patchrange, so we need to form threads for them using all
// comments
- const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+ const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
return createCommentThreads(allComments).filter(thread => {
// Robot comments and drafts are not ported over. A human reply to
@@ -398,7 +353,6 @@
return false;
}
- thread.diffSide = Side.RIGHT;
if (thread.commentSide === CommentSide.PARENT) {
// TODO(dhruvsri): Add handling for merge parents
if (
@@ -406,7 +360,6 @@
!!thread.mergeParentNum
)
return false;
- thread.diffSide = Side.LEFT;
}
if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
@@ -423,8 +376,7 @@
patchRange: PatchRange
): CommentThread[] {
const threads = createCommentThreads(
- this.getCommentsForFile(file, patchRange),
- patchRange
+ this.getCommentsForFile(file, patchRange)
);
threads.push(...this._getPortedCommentThreads(file, patchRange));
return threads;
@@ -442,7 +394,10 @@
* @param projectConfig Optional project config object to
* include in the meta sub-object.
*/
- getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+ getCommentsForFile(
+ file: PatchSetFile,
+ patchRange: PatchRange
+ ): CommentInfo[] {
const comments = this.getCommentsForPath(file.path, patchRange);
if (file.basePath) {
comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -464,11 +419,11 @@
file: PatchSetFile | PatchNumOnly,
ignorePatchsetLevelComments?: boolean
) {
- let comments: Comment[] = [];
+ let comments: CommentInfo[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
} else {
- comments = this._commentObjToArray(
+ comments = this._commentObjToArray<CommentInfo>(
this.getAllPublishedComments(file.patchNum)
);
}
@@ -579,8 +534,8 @@
file: PatchSetFile | PatchNumOnly,
ignorePatchsetLevelComments?: boolean
) {
- let comments: Comment[] = [];
- let drafts: Comment[] = [];
+ let comments: CommentInfo[] = [];
+ let drafts: CommentInfo[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 7e01371..9770261 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -20,7 +20,7 @@
import {ChangeComments} from './gr-comment-api.js';
import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
-import {CommentSide, Side} from '../../../constants/constants.js';
+import {CommentSide} from '../../../constants/constants.js';
import {stubRestApi} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-comment-api');
@@ -207,7 +207,6 @@
{path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
assert.equal(portedThreads.length, 1);
assert.equal(portedThreads[0].line, 31);
- assert.equal(portedThreads[0].diffSide, Side.LEFT);
assert.equal(changeComments._getPortedCommentThreads(
{path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
@@ -363,6 +362,7 @@
...createComment(),
id: '01',
patch_set: 2,
+ path: 'file/one',
side: PARENT,
line: 1,
updated: makeTime(1),
@@ -379,6 +379,7 @@
id: '02',
in_reply_to: '04',
patch_set: 2,
+ path: 'file/one',
unresolved: true,
line: 1,
updated: makeTime(3),
@@ -388,6 +389,7 @@
...createComment(),
id: '03',
patch_set: 2,
+ path: 'file/one',
side: PARENT,
line: 2,
updated: makeTime(1),
@@ -397,6 +399,7 @@
...createComment(),
id: '04',
patch_set: 2,
+ path: 'file/one',
line: 1,
updated: makeTime(1),
};
@@ -470,6 +473,7 @@
side: PARENT,
line: 1,
updated: makeTime(3),
+ path: 'file/one',
};
commentObjs['13'] = {
@@ -481,6 +485,7 @@
// Draft gets lower timestamp than published comment, because we
// want to test that the draft still gets sorted to the end.
updated: makeTime(2),
+ path: 'file/one',
};
commentObjs['14'] = {
@@ -597,10 +602,6 @@
const path = 'file/one';
const drafts = element._changeComments.getAllDraftsForPath(path);
assert.equal(drafts.length, 2);
- const aCopyOfDrafts = element._changeComments
- .getAllDraftsForPath(path);
- assert.deepEqual(drafts, aCopyOfDrafts);
- assert.notEqual(drafts[0], aCopyOfDrafts[0]);
});
test('computeUnresolvedNum', () => {
@@ -828,24 +829,6 @@
const threads = element._changeComments.getAllThreadsForChange();
assert.deepEqual(threads, expectedThreads);
});
-
- test('getCommentsForThreadGroup', () => {
- let expectedComments = [
- {...commentObjs['04'], path: 'file/one'},
- {...commentObjs['02'], path: 'file/one'},
- {...commentObjs['13'], path: 'file/one'},
- ];
- assert.deepEqual(element._changeComments.getCommentsForThread('04'),
- expectedComments);
-
- expectedComments = [{...commentObjs['12'], path: 'file/one'}];
-
- assert.deepEqual(element._changeComments.getCommentsForThread('12'),
- expectedComments);
-
- assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
- null);
- });
});
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
index 54b2450f..6b1294a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -39,7 +39,7 @@
/** CSS class for the currently hovered token. */
const CSS_HIGHLIGHT = 'token-highlight';
-export const HOVER_DELAY_MS = 200;
+export const HOVER_DELAY_MS = 500;
const LINE_LENGTH_LIMIT = 500;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index f8fb40c..25a1a00 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -25,9 +25,7 @@
import {
anyLineTooLong,
getLine,
- getRange,
getSide,
- rangesEqual,
SYNTAX_MAX_LINE_LENGTH,
} from '../gr-diff/gr-diff-utils';
import {getAppContext} from '../../../services/app-context';
@@ -37,7 +35,11 @@
isMergeParent,
isNumber,
} from '../../../utils/patch-set-util';
-import {CommentThread} from '../../../utils/comment-util';
+import {
+ CommentThread,
+ isInBaseOfPatchRange,
+ isInRevisionOfPatchRange,
+} from '../../../utils/comment-util';
import {customElement, observe, property} from '@polymer/decorators';
import {
CommitRange,
@@ -83,7 +85,6 @@
import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
import {Timing} from '../../../constants/reporting';
-import {changeComments$} from '../../../services/comments/comments-model';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
import {Subscription} from 'rxjs';
import {DisplayLine, RenderPreferences} from '../../../api/diff';
@@ -266,6 +267,8 @@
private readonly browserModel = getAppContext().browserModel;
+ private readonly commentsModel = getAppContext().commentsModel;
+
private readonly reporting = getAppContext().reportingService;
private readonly flags = getAppContext().flagsService;
@@ -288,7 +291,7 @@
// change in some way, and that we should update any models we may want
// to keep in sync.
'create-comment',
- e => this._handleCreateComment(e)
+ e => this._handleCreateThread(e)
);
this.addEventListener('render-start', () => this._handleRenderStart());
this.addEventListener('render-content', () => this._handleRenderContent());
@@ -318,7 +321,7 @@
this._loggedIn = loggedIn;
});
this.subscriptions.push(
- changeComments$.subscribe(changeComments => {
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this.changeComments = changeComments;
})
);
@@ -734,30 +737,29 @@
}
_threadsChanged(threads: CommentThread[]) {
- const threadEls = new Set<GrCommentThread>();
const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
for (const threadEl of this.getThreadEls()) {
if (threadEl.rootId) {
rootIdToThreadEl.set(threadEl.rootId, threadEl);
}
}
+ const dontRemove = new Set<GrCommentThread>();
for (const thread of threads) {
const existingThreadEl =
thread.rootId && rootIdToThreadEl.get(thread.rootId);
if (existingThreadEl) {
- this._updateThreadElement(existingThreadEl, thread);
- threadEls.add(existingThreadEl);
+ existingThreadEl.thread = thread;
+ dontRemove.add(existingThreadEl);
} else {
const threadEl = this._createThreadElement(thread);
this._attachThreadElement(threadEl);
- threadEls.add(threadEl);
+ dontRemove.add(threadEl);
}
}
// Remove all threads that are no longer existing.
for (const threadEl of this.getThreadEls()) {
- if (threadEls.has(threadEl)) continue;
- const parent = threadEl.parentNode;
- if (parent) parent.removeChild(threadEl);
+ if (dontRemove.has(threadEl)) continue;
+ threadEl.remove();
}
const portedThreadsCount = threads.filter(thread => thread.ported).length;
const portedThreadsWithoutRange = threads.filter(
@@ -785,10 +787,10 @@
);
}
- _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+ _handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
if (!this.patchRange) throw Error('patch range not set');
- const {lineNum, side, range, path} = e.detail;
+ const {lineNum, side, range} = e.detail;
// Usually, the comment is stored on the patchset shown on the side the
// user added the comment on, and the commentSide will be REVISION.
@@ -806,18 +808,27 @@
? CommentSide.PARENT
: CommentSide.REVISION;
if (!this.canCommentOnPatchSetNum(patchNum)) return;
- const threadEl = this._getOrCreateThread({
+ const path =
+ this.file?.basePath &&
+ side === Side.LEFT &&
+ commentSide === CommentSide.REVISION
+ ? this.file?.basePath
+ : this.path;
+ assertIsDefined(path, 'path');
+
+ const newThread: CommentThread = {
+ rootId: undefined,
comments: [],
- path,
- diffSide: side,
- commentSide,
patchNum,
+ commentSide,
+ // TODO: Maybe just compute from patchRange.base on the fly?
+ mergeParentNum: this._parentIndex ?? undefined,
+ path,
line: lineNum,
range,
- });
- threadEl.addOrEditDraft(lineNum, range);
-
- this.reporting.recordDraftInteraction();
+ };
+ const el = this._createThreadElement(newThread);
+ this._attachThreadElement(el);
}
private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
@@ -846,21 +857,6 @@
return true;
}
- /**
- * Gets or creates a comment thread at a given location.
- * May provide a range, to get/create a range comment.
- */
- _getOrCreateThread(thread: CommentThread): GrCommentThread {
- let threadEl = this._getThreadEl(thread);
- if (!threadEl) {
- threadEl = this._createThreadElement(thread);
- this._attachThreadElement(threadEl);
- } else {
- this._updateThreadElement(threadEl, thread);
- }
- return threadEl;
- }
-
_attachThreadElement(threadEl: Element) {
this.$.diff.appendChild(threadEl);
}
@@ -873,67 +869,38 @@
}
_createThreadElement(thread: CommentThread) {
+ assertIsDefined(this.patchRange, 'patchRange');
+ const commentProps = {
+ patch_set: thread.patchNum,
+ side: thread.commentSide,
+ parent: thread.mergeParentNum,
+ };
+ let diffSide: Side;
+ if (isInBaseOfPatchRange(commentProps, this.patchRange)) {
+ diffSide = Side.LEFT;
+ } else if (isInRevisionOfPatchRange(commentProps, this.patchRange)) {
+ diffSide = Side.RIGHT;
+ } else {
+ const propsStr = JSON.stringify(commentProps);
+ const rangeStr = JSON.stringify(this.patchRange);
+ throw new Error(`comment ${propsStr} not in range ${rangeStr}`);
+ }
+
const threadEl = document.createElement('gr-comment-thread');
threadEl.className = 'comment-thread';
- threadEl.setAttribute(
- 'slot',
- `${thread.diffSide}-${thread.line || 'LOST'}`
- );
- this._updateThreadElement(threadEl, thread);
- return threadEl;
- }
-
- _updateThreadElement(threadEl: GrCommentThread, thread: CommentThread) {
- threadEl.comments = thread.comments;
- threadEl.diffSide = thread.diffSide;
- threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
- threadEl.parentIndex = this._parentIndex;
- // Use path before renmaing when comment added on the left when comparing
- // two patch sets (not against base)
- if (
- this.file &&
- this.file.basePath &&
- thread.diffSide === Side.LEFT &&
- !threadEl.isOnParent
- ) {
- threadEl.path = this.file.basePath;
- } else {
- threadEl.path = this.path;
- }
- threadEl.changeNum = this.changeNum;
- threadEl.patchNum = thread.patchNum;
+ threadEl.rootId = thread.rootId;
+ threadEl.thread = thread;
threadEl.showPatchset = false;
threadEl.showPortedComment = !!thread.ported;
- if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
- // GrCommentThread does not understand 'FILE', but requires undefined.
- else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
- threadEl.projectName = this.projectName;
- threadEl.range = thread.range;
- }
-
- /**
- * Gets a comment thread element at a given location.
- * May provide a range, to get a range comment.
- */
- _getThreadEl(thread: CommentThread): GrCommentThread | null {
- let line: LineInfo;
- if (thread.diffSide === Side.LEFT) {
- line = {beforeNumber: thread.line};
- } else if (thread.diffSide === Side.RIGHT) {
- line = {afterNumber: thread.line};
- } else {
- throw new Error(`Unknown side: ${thread.diffSide}`);
+ // These attributes are the "interface" between comment threads and gr-diff.
+ // <gr-comment-thread> does not care about them and is not affected by them.
+ threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+ threadEl.setAttribute('diff-side', `${diffSide}`);
+ threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+ if (thread.range) {
+ threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
}
- function matchesRange(threadEl: GrCommentThread) {
- return rangesEqual(getRange(threadEl), thread.range);
- }
-
- const filteredThreadEls = this._filterThreadElsForLocation(
- this.getThreadEls(),
- line,
- thread.diffSide
- ).filter(matchesRange);
- return filteredThreadEls.length ? filteredThreadEls[0] : null;
+ return threadEl;
}
_filterThreadElsForLocation(
@@ -1181,8 +1148,6 @@
'normalize-range': CustomEvent;
'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
'create-comment': CustomEvent;
- 'comment-update': CustomEvent;
- 'comment-save': CustomEvent;
'root-id-changed': CustomEvent;
}
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index dd15462..6149c82 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -948,7 +948,6 @@
});
test('creates comments if they do not exist yet', () => {
- const diffSide = Side.LEFT;
element.patchRange = {
basePatchNum: 'PARENT',
patchNum: 2,
@@ -957,7 +956,7 @@
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
lineNum: 3,
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
},
}));
@@ -966,10 +965,10 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.isTrue(threads[0].isOnParent);
- assert.equal(threads[0].range, undefined);
- assert.equal(threads[0].patchNum, 2);
+ assert.equal(threads[0].thread.commentSide, 'PARENT');
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[0].thread.range, undefined);
+ assert.equal(threads[0].thread.patchNum, 2);
// Try to fetch a thread with a different range.
const range = {
@@ -986,7 +985,7 @@
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
lineNum: 1,
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
range,
},
@@ -996,10 +995,10 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 2);
- assert.equal(threads[1].diffSide, diffSide);
- assert.isTrue(threads[0].isOnParent);
- assert.equal(threads[1].range, range);
- assert.equal(threads[1].patchNum, 3);
+ assert.equal(threads[0].thread.commentSide, 'PARENT');
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[1].thread.range, range);
+ assert.equal(threads[1].thread.patchNum, 3);
});
test('should not be on parent if on the right', () => {
@@ -1014,10 +1013,11 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isFalse(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'REVISION');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
});
test('should be on parent if right and base is PARENT', () => {
@@ -1032,10 +1032,11 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isTrue(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'PARENT');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
});
test('should be on parent if right and base negative', () => {
@@ -1050,10 +1051,11 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isTrue(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'PARENT');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
});
test('should not be on parent otherwise', () => {
@@ -1068,24 +1070,25 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isFalse(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'REVISION');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
});
test('thread should use old file path if first created ' +
- 'on patch set (left) before renaming', () => {
- const diffSide = Side.LEFT;
+ 'on patch set (left) before renaming', async () => {
element.patchRange = {
basePatchNum: 2,
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
},
}));
@@ -1094,22 +1097,22 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.equal(threads[0].path, element.file.basePath);
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[0].thread.path, element.file.basePath);
});
- test('thread should use new file path if first created' +
- 'on patch set (right) after renaming', () => {
- const diffSide = Side.RIGHT;
+ test('thread should use new file path if first created ' +
+ 'on patch set (right) after renaming', async () => {
element.patchRange = {
basePatchNum: 2,
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
- side: diffSide,
+ side: Side.RIGHT,
path: '/p',
},
}));
@@ -1118,23 +1121,27 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.equal(threads[0].path, element.file.path);
+ assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+ assert.equal(threads[0].thread.path, element.file.path);
});
- test('multiple threads created on the same range', () => {
+ test('multiple threads created on the same range', async () => {
element.patchRange = {
basePatchNum: 2,
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
- const comment = createComment();
- comment.range = {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 2,
+ const comment = {
+ ...createComment(),
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 2,
+ },
+ patch_set: 3,
};
const thread = createCommentThread([comment]);
element.threads = [thread];
@@ -1159,18 +1166,18 @@
assert.equal(threads.length, 2);
});
- test('thread should use new file path if first created' +
- 'on patch set (left) but is base', () => {
- const diffSide = Side.LEFT;
+ test('thread should use new file path if first created ' +
+ 'on patch set (left) but is base', async () => {
element.patchRange = {
basePatchNum: 'PARENT',
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
},
}));
@@ -1179,8 +1186,8 @@
dom(element.$.diff).queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.equal(threads[0].path, element.file.path);
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[0].thread.path, element.file.path);
});
test('cannot create thread on an edit', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 0d63360..fd30c6a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -50,7 +50,7 @@
// Private but accessed by tests.
readonly browserModel = getAppContext().browserModel;
- private readonly userService = getAppContext().userService;
+ private readonly userModel = getAppContext().userModel;
private subscriptions: Subscription[] = [];
@@ -83,7 +83,7 @@
*/
setMode(newMode: DiffViewMode) {
if (this.saveOnChange && this.mode && this.mode !== newMode) {
- this.userService.updatePreferences({diff_view: newMode});
+ this.userModel.updatePreferences({diff_view: newMode});
}
this.mode = newMode;
let announcement;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 7f7f265..f469799 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -19,7 +19,6 @@
import './gr-diff-preferences-dialog';
import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {updateDiffPreferences} from '../../../services/user/user-model';
import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
@@ -37,7 +36,6 @@
line_wrapping: true,
};
element.diffPrefs = originalDiffPrefs;
- updateDiffPreferences(originalDiffPrefs);
await flush();
element.open();
await flush();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index c4e2488..ff02d61 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -31,6 +31,8 @@
import '../gr-diff-mode-selector/gr-diff-mode-selector';
import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import '../gr-patch-range-select/gr-patch-range-select';
+import '../../change/gr-download-dialog/gr-download-dialog';
+import '../../shared/gr-overlay/gr-overlay';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-diff-view_html';
@@ -82,6 +84,7 @@
RepoName,
RevisionInfo,
RevisionPatchSetNum,
+ ServerInfo,
} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {
@@ -105,29 +108,17 @@
import {AppElementParams, AppElementDiffViewParam} from '../../gr-app-types';
import {EventType, OpenFixPreviewEvent} from '../../../types/events';
import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
import {assertIsDefined} from '../../../utils/common-util';
import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
import {CursorMoveResult} from '../../../api/core';
import {isFalse, throttleWrap, until} from '../../../utils/async-util';
-import {
- changeComments$,
- commentsLoading$,
-} from '../../../services/comments/comments-model';
import {filter, take} from 'rxjs/operators';
import {Subscription, combineLatest} from 'rxjs';
import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {
- preferences$,
- diffPreferences$,
-} from '../../../services/user/user-model';
-import {
- diffPath$,
- currentPatchNum$,
- change$,
- changeLoading$,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
import {DisplayLine} from '../../../api/diff';
+import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
const LOADING_BLAME = 'Loading blame...';
@@ -154,6 +145,8 @@
diffPreferencesDialog: GrOverlay;
applyFixDialog: GrApplyFixDialog;
modeSelect: GrDiffModeSelector;
+ downloadOverlay: GrOverlay;
+ downloadDialog: GrDownloadDialog;
};
}
@@ -236,6 +229,9 @@
_projectConfig?: ConfigInfo;
@property({type: Object})
+ _serverConfig?: ServerInfo;
+
+ @property({type: Object})
_userPrefs?: PreferencesInfo;
@property({type: Boolean})
@@ -359,16 +355,20 @@
private readonly restApiService = getAppContext().restApiService;
- private readonly userService = getAppContext().userService;
+ // Private but used in tests.
+ readonly routerModel = getAppContext().routerModel;
- private readonly changeService = getAppContext().changeService;
+ // Private but used in tests.
+ readonly userModel = getAppContext().userModel;
- // Private but used in tests
+ // Private but used in tests.
+ readonly changeModel = getAppContext().changeModel;
+
+ // Private but used in tests.
readonly browserModel = getAppContext().browserModel;
- // We just want to make sure that CommentsService is instantiated.
- // Otherwise subscribing to the model won't emit any data.
- private readonly _commentsService = getAppContext().commentsService;
+ // Private but used in tests.
+ readonly commentsModel = getAppContext().commentsModel;
private readonly shortcuts = getAppContext().shortcutsService;
@@ -380,11 +380,6 @@
private subscriptions: Subscription[] = [];
- constructor() {
- super();
- this._commentsService;
- }
-
override connectedCallback() {
super.connectedCallback();
this._throttledToggleFileReviewed = throttleWrap(_ =>
@@ -393,25 +388,28 @@
this._getLoggedIn().then(loggedIn => {
this._loggedIn = loggedIn;
});
+ this.restApiService.getConfig().then(config => {
+ this._serverConfig = config;
+ });
this.subscriptions.push(
- changeComments$.subscribe(changeComments => {
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this._changeComments = changeComments;
})
);
this.subscriptions.push(
- preferences$.subscribe(preferences => {
+ this.userModel.preferences$.subscribe(preferences => {
this._userPrefs = preferences;
})
);
this.subscriptions.push(
- diffPreferences$.subscribe(diffPreferences => {
+ this.userModel.diffPreferences$.subscribe(diffPreferences => {
this._prefs = diffPreferences;
})
);
this.subscriptions.push(
- change$.subscribe(change => {
+ this.changeModel.change$.subscribe(change => {
// The diff view is tied to a specfic change number, so don't update
// _change to undefined.
if (change) this._change = change;
@@ -423,7 +421,12 @@
// properties since the method will be called anytime a property updates
// but we only want to call this on the initial load.
this.subscriptions.push(
- combineLatest(currentPatchNum$, routerView$, diffPath$, diffPreferences$)
+ combineLatest([
+ this.changeModel.currentPatchNum$,
+ this.routerModel.routerView$,
+ this.changeModel.diffPath$,
+ this.userModel.diffPreferences$,
+ ])
.pipe(
filter(
([currentPatchNum, routerView, path, diffPrefs]) =>
@@ -438,7 +441,9 @@
this.setReviewedStatus(currentPatchNum!, path!, diffPrefs);
})
);
- this.subscriptions.push(diffPath$.subscribe(path => (this._path = path)));
+ this.subscriptions.push(
+ this.changeModel.diffPath$.subscribe(path => (this._path = path))
+ );
this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
this.cursor.replaceDiffs([this.$.diffHost]);
this._onRenderHandler = (_: Event) => {
@@ -516,7 +521,7 @@
_getChangeEdit() {
assertIsDefined(this._changeNum, '_changeNum');
- return this.restApiService.getChangeEdit(this._changeNum);
+ return this.restApiService.getChangeEdit(this._changeNum, true);
}
_getSortedFileList(files?: Files) {
@@ -768,8 +773,16 @@
}
_handleOpenDownloadDialog() {
- this.set('changeViewState.showDownloadDialog', true);
- this._navToChangeView();
+ this.$.downloadOverlay.open().then(() => {
+ this.$.downloadOverlay.setFocusStops(
+ this.$.downloadDialog.getFocusStops()
+ );
+ this.$.downloadDialog.focus();
+ });
+ }
+
+ _handleDownloadDialogClose() {
+ this.$.downloadOverlay.close();
}
_handleUpToChange() {
@@ -784,9 +797,9 @@
_handleToggleDiffMode() {
if (!this._userPrefs) return;
if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
- this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+ this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
} else {
- this.userService.updatePreferences({
+ this.userModel.updatePreferences({
diff_view: DiffViewMode.SIDE_BY_SIDE,
});
}
@@ -1046,7 +1059,7 @@
GerritNav.navigateToChange(this._change);
return;
}
- this.changeService.updatePath(comment.path);
+ this.changeModel.updatePath(comment.path);
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
if (!latestPatchNum) throw new Error('Missing _allPatchSets');
@@ -1056,7 +1069,7 @@
this._focusLineNum = comment.line;
} else {
if (this.params.path) {
- this.changeService.updatePath(this.params.path);
+ this.changeModel.updatePath(this.params.path);
}
if (this.params.patchNum) {
this._patchRange = {
@@ -1122,7 +1135,7 @@
}
this._files = {sortedFileList: [], changeFilesByPath: {}};
- this.changeService.updatePath(undefined);
+ this.changeModel.updatePath(undefined);
this._patchRange = undefined;
this._commitRange = undefined;
this._focusLineNum = undefined;
@@ -1146,8 +1159,15 @@
}
const promises: Promise<unknown>[] = [];
- if (!this._change) promises.push(until(changeLoading$, isFalse));
- promises.push(until(commentsLoading$, isFalse));
+ if (!this._change) {
+ promises.push(
+ until(
+ this.changeModel.changeLoadingStatus$,
+ status => status === LoadingStatus.LOADED
+ )
+ );
+ }
+ promises.push(until(this.commentsModel.commentsLoading$, isFalse));
promises.push(
this._getChangeEdit().then(edit => {
if (edit) {
@@ -1764,7 +1784,7 @@
}
_handleReloadingDiffPreference() {
- this.userService.getDiffPreferences();
+ this.userModel.getDiffPreferences();
}
_computeCanEdit(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 59e15a7..ef38440 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -425,4 +425,13 @@
on-reload-diff-preference="_handleReloadingDiffPreference"
>
</gr-diff-preferences-dialog>
+ <gr-overlay id="downloadOverlay">
+ <gr-download-dialog
+ id="downloadDialog"
+ change="[[_change]]"
+ patch-num="[[_patchRange.patchNum]]"
+ config="[[_serverConfig.download]]"
+ on-close="_handleDownloadDialogClose"
+ ></gr-download-dialog>
+ </gr-overlay>
`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 2367342..b2d5a27 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -21,7 +21,7 @@
import {ChangeStatus, DiffViewMode, createDefaultDiffPrefs} from '../../../constants/constants.js';
import {stubRestApi, stubUsers, waitUntil} from '../../../test/test-utils.js';
import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
-import {GerritView, _testOnly_setState as setRouterModelState} from '../../../services/router/router-model.js';
+import {GerritView} from '../../../services/router/router-model.js';
import {
createChange,
createRevisions,
@@ -31,9 +31,6 @@
import {EditPatchSetNum} from '../../../types/common.js';
import {CursorMoveResult} from '../../../api/core.js';
import {Side} from '../../../api/diff.js';
-import {_testOnly_setState as setUserModelState, _testOnly_getState as getUserModelState} from '../../../services/user/user-model.js';
-import {_testOnly_setState as setChangeModelState} from '../../../services/change/change-model.js';
-import {_testOnly_setState as setCommentState} from '../../../services/comments/comments-model.js';
const basicFixture = fixtureFromElement('gr-diff-view');
@@ -94,7 +91,7 @@
]});
await flush();
- setCommentState({
+ element.commentsModel.setState({
comments: {},
robotComments: {},
drafts: {},
@@ -140,14 +137,15 @@
sinon.stub(element.reporting, 'diffViewDisplayed');
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.spy(element, '_paramsChanged');
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
});
test('comment url resolves to comment.patch_set vs latest', () => {
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -221,7 +219,7 @@
test('unchanged diff X vs latest from comment links navigates to base vs X'
, () => {
const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -249,10 +247,11 @@
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.stub(element, '_isFileUnchanged').returns(true);
sinon.spy(element, '_paramsChanged');
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
@@ -273,7 +272,7 @@
test('unchanged diff Base vs latest from comment does not navigate'
, () => {
const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -301,10 +300,11 @@
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.stub(element, '_isFileUnchanged').returns(true);
sinon.spy(element, '_paramsChanged');
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
@@ -353,7 +353,7 @@
});
test('diff toast to go to latest is shown and not base', async () => {
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -382,10 +382,11 @@
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.spy(element, '_paramsChanged');
element._change = undefined;
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
element._patchRange = {
patchNum: 2,
basePatchNum: 1,
@@ -797,9 +798,10 @@
{patchNum: 10, basePatchNum: 5}),
'Should navigate to /c/42/5..10');
- assert.isUndefined(element.changeViewState.showDownloadDialog);
+ const downloadOverlayStub = sinon.stub(element.$.downloadOverlay, 'open')
+ .returns(Promise.resolve());
MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
- assert.isTrue(element.changeViewState.showDownloadDialog);
+ assert.isTrue(downloadOverlayStub.called);
});
test('keyboard shortcuts with old patch number', () => {
@@ -1199,10 +1201,12 @@
...createDefaultDiffPrefs(),
manual_review: true,
};
- setUserModelState({...getUserModelState(), diffPreferences});
- setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+ element.userModel.setDiffPreferences(diffPreferences);
+ element.changeModel.setState({
+ change: createChange(),
+ diffPath: '/COMMIT_MSG'});
- setRouterModelState({
+ element.routerModel.setState({
changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
);
element._patchRange = {
@@ -1216,8 +1220,7 @@
assert.isTrue(getReviewedStub.called);
// if prefs are updated then the reviewed status should not be set again
- setUserModelState({...getUserModelState(),
- diffPreferences: createDefaultDiffPrefs()});
+ element.userModel.setDiffPreferences(createDefaultDiffPrefs());
await flush();
assert.isFalse(saveReviewedStub.called);
@@ -1237,11 +1240,12 @@
...createDefaultDiffPrefs(),
manual_review: false,
};
- setUserModelState({...getUserModelState(), diffPreferences});
- setChangeModelState({change: createChange(),
+ element.userModel.setDiffPreferences(diffPreferences);
+ element.changeModel.setState({
+ change: createChange(),
diffPath: '/COMMIT_MSG'});
- setRouterModelState({
+ element.routerModel.setState({
changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF,
patchNum: 22}
);
@@ -1262,11 +1266,12 @@
.callsFake(() => Promise.resolve());
sinon.stub(element.$.diffHost, 'reload');
- setUserModelState({...getUserModelState(),
- diffPreferences: createDefaultDiffPrefs()});
- setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+ element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+ element.changeModel.setState({
+ change: createChange(),
+ diffPath: '/COMMIT_MSG'});
- setRouterModelState({
+ element.routerModel.setState({
changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
);
@@ -2048,7 +2053,6 @@
stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
stubRestApi('getProjectConfig').returns(Promise.resolve({}));
- stubRestApi('getDiffChangeDetail').returns(Promise.resolve({}));
stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
stubRestApi('saveFileReviewed').returns(Promise.resolve());
stubRestApi('getDiffComments').returns(Promise.resolve({}));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 7393606..63db013 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -124,7 +124,7 @@
// For Gerrit these are instances of GrCommentThread, but other gr-diff users
// have different HTML elements in use for comment threads.
// TODO: Also document the required HTML attributes that thread elements must
-// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
+// have, e.g. 'diff-side', 'range', 'line-num'.
export interface GrDiffThreadElement extends HTMLElement {
rootId: string;
}
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 3a2def0..71fee62 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -51,7 +51,6 @@
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators';
import {subscribe} from '../../lit/subscription-controller';
-import {changeComments$} from '../../../services/comments/comments-model';
// Maximum length for patch set descriptions.
const PATCH_DESC_MAX_LENGTH = 500;
@@ -127,9 +126,15 @@
private readonly reporting: ReportingService =
getAppContext().reportingService;
+ private readonly commentsModel = getAppContext().commentsModel;
+
constructor() {
super();
- subscribe(this, changeComments$, x => (this.changeComments = x));
+ subscribe(
+ this,
+ this.commentsModel.changeComments$,
+ x => (this.changeComments = x)
+ );
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index d9bc901..e879078 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -29,13 +29,13 @@
import {computeTruncatedPath} from '../../../utils/path-list-util';
import {customElement, observe, property} from '@polymer/decorators';
import {
- ChangeInfo,
PatchSetNum,
EditPreferencesInfo,
Base64FileContent,
NumericChangeId,
EditPatchSetNum,
} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
import {HttpMethod, NotifyType} from '../../../constants/constants';
import {fireAlert, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
@@ -90,7 +90,7 @@
params?: GenerateUrlEditViewParameters;
@property({type: Object, observer: '_editChange'})
- _change?: ChangeInfo | null;
+ _change?: ParsedChangeInfo | null;
@property({type: Number})
_changeNum?: NumericChangeId;
@@ -153,13 +153,13 @@
this._prefs = prefs;
});
this.cleanups.push(
- addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, e =>
- this._handleSaveShortcut(e)
+ addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
+ this._handleSaveShortcut()
)
);
this.cleanups.push(
- addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, e =>
- this._handleSaveShortcut(e)
+ addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, () =>
+ this._handleSaveShortcut()
)
);
}
@@ -211,13 +211,11 @@
return Promise.all(promises);
}
- _getChangeDetail(changeNum: NumericChangeId) {
- return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
- this._change = change;
- });
+ async _getChangeDetail(changeNum: NumericChangeId) {
+ this._change = await this.restApiService.getChangeDetail(changeNum);
}
- _editChange(value?: ChangeInfo | null) {
+ _editChange(value?: ParsedChangeInfo | null) {
if (!value) return;
if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
fireAlert(
@@ -228,7 +226,7 @@
}
@observe('_change', '_type')
- _editType(change?: ChangeInfo | null, type?: string) {
+ _editType(change?: ParsedChangeInfo | null, type?: string) {
if (!change || !type || !type.startsWith('image/')) return;
// Prevent editing binary files
@@ -402,8 +400,7 @@
);
}
- _handleSaveShortcut(e: KeyboardEvent) {
- e.preventDefault();
+ _handleSaveShortcut() {
if (!this._saveDisabled) {
this._saveEdit();
}
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index dc6d8d5..07f3851 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -16,6 +16,7 @@
*/
import '../../../test/common-test-setup-karma';
+import './gr-editor-view';
import {GrEditorView} from './gr-editor-view';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {HttpMethod} from '../../../constants/constants';
@@ -45,7 +46,7 @@
element = basicFixture.instantiate();
savePathStub = stubRestApi('renameFileInChangeEdit');
saveFileStub = stubRestApi('saveChangeEdit');
- changeDetailStub = stubRestApi('getDiffChangeDetail');
+ changeDetailStub = stubRestApi('getChangeDetail');
navigateStub = sinon.stub(element, '_viewEditInChangeView');
});
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c89fe20..d20a42e 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -235,10 +235,6 @@
constructor() {
super();
- // We just want to instantiate this service somewhere. It is reacting to
- // model changes and updates the config model, but at the moment the service
- // is not called from anywhere.
- getAppContext().configService;
document.addEventListener(EventType.PAGE_ERROR, e => {
this._handlePageError(e);
});
@@ -302,7 +298,6 @@
patchRange: null,
selectedFileIndex: 0,
showReplyDialog: false,
- showDownloadDialog: false,
diffMode: null,
numFilesShown: null,
},
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index de749df..d0525ea 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -26,10 +26,11 @@
import {page} from '../utils/page-wrapper-utils';
import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {AppContext} from '../services/app-context';
-export function initGlobalVariables() {
+export function initGlobalVariables(appContext: AppContext) {
window.GrAnnotation = GrAnnotation;
window.page = page;
window.GrPluginActionContext = GrPluginActionContext;
- initGerritPluginApi();
+ initGerritPluginApi(appContext);
}
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
deleted file mode 100644
index c63df04..0000000
--- a/polygerrit-ui/app/elements/gr-app-init.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {createAppContext} from '../services/app-context-init';
-import {
- initVisibilityReporter,
- initPerformanceReporter,
- initErrorReporter,
-} from '../services/gr-reporting/gr-reporting_impl';
-import {injectAppContext} from '../services/app-context';
-
-const appContext = createAppContext();
-injectAppContext(appContext);
-const reportingService = appContext.reportingService;
-initVisibilityReporter(reportingService);
-initPerformanceReporter(reportingService);
-initErrorReporter(reportingService);
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 463fab9..a8da03a 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -16,7 +16,6 @@
*/
import {safeTypesBridge} from '../utils/safe-types-util';
-import './gr-app-init';
import './font-roboto-local-loader';
// Sets up global Polymer variable, because plugins requires it.
import '../scripts/bundled-polymer';
@@ -38,10 +37,24 @@
import './gr-app-element';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-app_html';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
import {customElement} from '@polymer/decorators';
import {installPolymerResin} from '../scripts/polymer-resin-install';
+import {createAppContext} from '../services/app-context-init';
+import {
+ initVisibilityReporter,
+ initPerformanceReporter,
+ initErrorReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {injectAppContext} from '../services/app-context';
+
+const appContext = createAppContext();
+injectAppContext(appContext);
+const reportingService = appContext.reportingService;
+initVisibilityReporter(reportingService);
+initPerformanceReporter(reportingService);
+initErrorReporter(reportingService);
+
installPolymerResin(safeTypesBridge);
@customElement('gr-app')
@@ -57,5 +70,4 @@
}
}
-initGlobalVariables();
-initGerritPluginApi();
+initGlobalVariables(appContext);
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
new file mode 100644
index 0000000..50a2782
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -0,0 +1,77 @@
+/**
+ * @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 {ReactiveController, ReactiveControllerHost} from 'lit';
+import {Binding} from '../../utils/dom-util';
+import {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {getAppContext} from '../../services/app-context';
+
+interface ShortcutListener {
+ binding: Binding;
+ listener: (e: KeyboardEvent) => void;
+}
+
+type Cleanup = () => void;
+
+export class ShortcutController implements ReactiveController {
+ private readonly service: ShortcutsService = getAppContext().shortcutsService;
+
+ private readonly listenersLocal: ShortcutListener[] = [];
+
+ private readonly listenersGlobal: ShortcutListener[] = [];
+
+ private cleanups: Cleanup[] = [];
+
+ constructor(private readonly host: ReactiveControllerHost & HTMLElement) {
+ host.addController(this);
+ }
+
+ // Note that local shortcuts are *not* suppressed when the user has shortcuts
+ // disabled or when the event comes from elements like <input>. So this method
+ // is intended for shortcuts like ESC and Ctrl-ENTER.
+ // If you need suppressed local shortcuts, then just add an options parameter.
+ addLocal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+ this.listenersLocal.push({binding, listener});
+ }
+
+ addGlobal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+ this.listenersGlobal.push({binding, listener});
+ }
+
+ hostConnected() {
+ for (const {binding, listener} of this.listenersLocal) {
+ const cleanup = this.service.addShortcut(this.host, binding, listener, {
+ shouldSuppress: false,
+ });
+ this.cleanups.push(cleanup);
+ }
+ for (const {binding, listener} of this.listenersGlobal) {
+ const cleanup = this.service.addShortcut(
+ document.body,
+ binding,
+ listener
+ );
+ this.cleanups.push(cleanup);
+ }
+ }
+
+ hostDisconnected() {
+ for (const cleanup of this.cleanups) {
+ cleanup();
+ }
+ this.cleanups = [];
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
index 9a8f75e..6fd2505 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
@@ -18,16 +18,13 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-admin-api tests', () => {
let adminApi;
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
getPluginLoader().loadPlugins([]);
adminApi = plugin.admin();
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index 2d83012..94eb292 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
Polymer({
is: 'gr-attribute-helper-some-element',
@@ -31,15 +30,13 @@
const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-attribute-helper tests', () => {
let element;
let instance;
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
element = basicFixture.instantiate();
instance = plugin.attributeHelper(element);
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 6484f92..e1f3d3c 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
@@ -43,7 +43,7 @@
export class GrChecksApi implements ChecksPluginApi {
private state = State.NOT_REGISTERED;
- private readonly checksService = getAppContext().checksService;
+ private readonly checksModel = getAppContext().checksModel;
private readonly reporting = getAppContext().reportingService;
@@ -53,14 +53,14 @@
announceUpdate() {
this.reporting.trackApi(this.plugin, 'checks', 'announceUpdate');
- this.checksService.reload(this.plugin.getPluginName());
+ this.checksModel.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);
+ this.checksModel.updateResult(this.plugin.getPluginName(), run, result);
}
register(provider: ChecksProvider, config?: ChecksApiConfig): void {
@@ -68,7 +68,7 @@
if (this.state === State.REGISTERED)
throw new Error('Only one provider can be registered per plugin.');
this.state = State.REGISTERED;
- this.checksService.register(
+ this.checksModel.register(
this.plugin.getPluginName(),
provider,
config ?? DEFAULT_CONFIG
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
index e1ec158..596c54b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -17,18 +17,15 @@
import '../../../test/common-test-setup-karma';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
import {PluginApi} from '../../../api/plugin';
import {ChecksPluginApi} from '../../../api/checks';
-const gerritPluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-settings-api tests', () => {
let checksApi: ChecksPluginApi | undefined;
setup(() => {
let pluginApi: PluginApi | undefined = undefined;
- gerritPluginApi.install(
+ window.Gerrit.install(
p => {
pluginApi = p;
},
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
index 883f2a6..025f2b4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -18,9 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-dom-hooks tests', () => {
let instance;
@@ -28,7 +25,7 @@
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
instance = new GrDomHooksManager(plugin);
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
index 1be5e82..893f0d1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -21,9 +21,6 @@
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {resetPlugins} from '../../../test/test-utils.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
const basicFixture = fixtureFromTemplate(
html`<div>
@@ -54,7 +51,9 @@
setup(async () => {
resetPlugins();
container = basicFixture.instantiate();
- pluginApi.install(p => plugin = p, '0.1',
+ window.Gerrit.install(
+ p => { plugin = p; },
+ '0.1',
'http://some/plugin/url.js');
// Decoration
decorationHook = plugin.registerCustomComponent('first', 'some-module');
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
index 4e3d657..13bd535 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -18,7 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {mockPromise} from '../../../test/test-utils.js';
Polymer({
@@ -34,15 +33,13 @@
const basicFixture = fixtureFromElement('gr-event-helper-some-element');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-event-helper tests', () => {
let element;
let instance;
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
element = basicFixture.instantiate();
instance = plugin.eventHelper(element);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
index a192f80..faf7525 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -18,11 +18,8 @@
import {resetPlugins} from '../../../test/test-utils.js';
import './gr-external-style.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
const basicFixture = fixtureFromTemplate(
html`<gr-external-style name="foo"></gr-external-style>`
);
@@ -35,7 +32,7 @@
const installPlugin = () => {
if (plugin) { return; }
- pluginApi.install(p => {
+ window.Gerrit.install(p => {
plugin = p;
}, '0.1', TEST_URL);
};
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
index 2889333..beedfab 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -18,7 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import {GrPopupInterface} from './gr-popup-interface.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
@@ -34,14 +33,13 @@
const containerFixture = fixtureFromElement('div');
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-popup-interface tests', () => {
let container;
let instance;
let plugin;
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
container = containerFixture.instantiate();
sinon.stub(plugin, 'hook').returns({
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index f1813a4..282aa11 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -69,6 +69,72 @@
await flush();
});
+ test('renders', () => {
+ expect(element).shadowDom.to.equal(`<div class="gr-form-styles">
+ <section>
+ <span class="title"></span>
+ <span class="value">
+ <gr-avatar hidden="" imagesize="120"></gr-avatar>
+ </span>
+ </section>
+ <section class="hide">
+ <span class="title"></span>
+ <span class="value"><a href="">Change avatar</a></span>
+ </section>
+ <section>
+ <span class="title">ID</span>
+ <span class="value">123</span>
+ </section>
+ <section>
+ <span class="title">Email</span>
+ <span class="value">user-123@</span>
+ </section>
+ <section>
+ <span class="title">Registered</span>
+ <span class="value">
+ <gr-date-formatter withtooltip=""></gr-date-formatter>
+ </span>
+ </section>
+ <section id="usernameSection">
+ <span class="title">Username</span>
+ <span class="value"></span>
+ <span class="value" hidden="true">
+ <iron-input id="usernameIronInput">
+ <input id="usernameInput">
+ </iron-input>
+ </span>
+ </section>
+ <section id="nameSection">
+ <label class="title" for="nameInput">Full name</label>
+ <span class="value">User-123</span>
+ <span class="value" hidden="true">
+ <iron-input id="nameIronInput">
+ <input id="nameInput">
+ </iron-input>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="displayNameInput">Display name</label>
+ <span class="value">
+ <iron-input>
+ <input id="displayNameInput">
+ </iron-input>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="statusInput">
+ Status (e.g. "Vacation")
+ </label>
+ <span class="value">
+ <iron-input>
+ <input id="statusInput">
+ </iron-input>
+ </span>
+ </section>
+ </div>
+ `);
+ });
+
test('basic account info render', () => {
assert.isFalse(element._loading);
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 8b47437..0a9fbbf 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -24,6 +24,7 @@
import {ServerInfo} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
+import {KnownExperimentId} from '../../../services/flags/flags';
@customElement('gr-change-table-editor')
export class GrChangeTableEditor extends PolymerElement {
@@ -48,24 +49,25 @@
@observe('serverConfig')
_configChanged(config: ServerInfo) {
this.defaultColumns = columnNames.filter(col =>
- this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+ this._isColumnEnabled(col, config)
);
if (!this.displayedColumns) return;
this.displayedColumns = this.displayedColumns.filter(column =>
- this._isColumnEnabled(
- column,
- config,
- this.flagsService.enabledExperiments
- )
+ this._isColumnEnabled(column, config)
);
}
/**
* Is the column disabled by a server config or experiment?
*/
- _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+ _isColumnEnabled(column: string, config: ServerInfo) {
if (!config || !config.change) return true;
- if (column === 'Comments') return experiments.includes('comments-column');
+ if (column === 'Comments')
+ return this.flagsService.isEnabled('comments-column');
+ if (column === 'Requirements')
+ return this.flagsService.isEnabled(
+ KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+ );
return true;
}
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index 3813213..c2bcec2 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -104,7 +104,7 @@
test('_getDisplayedColumns', () => {
const enabledColumns = columns.filter(column =>
- element._isColumnEnabled(column, element.serverConfig!, [])
+ element._isColumnEnabled(column, element.serverConfig!)
);
assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
const input = queryAndAssert<HTMLInputElement>(
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index e7137e4..7216502 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -103,19 +103,19 @@
override connectedCallback() {
super.connectedCallback();
this.cleanups.push(
- addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+ addShortcut(this, {key: Key.UP}, () => this._handleUp())
);
this.cleanups.push(
- addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+ addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
);
this.cleanups.push(
- addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+ addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
);
this.cleanups.push(
- addShortcut(this, {key: Key.ESC}, _ => this._handleEscape())
+ addShortcut(this, {key: Key.ESC}, () => this._handleEscape())
);
this.cleanups.push(
- addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+ addShortcut(this, {key: Key.TAB}, () => this._handleTab())
);
}
@@ -141,37 +141,23 @@
return this.getCursorTarget()?.dataset['value'] || '';
}
- _handleUp(e: Event) {
- if (!this.isHidden) {
- e.preventDefault();
- e.stopPropagation();
- this.cursorUp();
- }
+ _handleUp() {
+ if (!this.isHidden) this.cursorUp();
}
- _handleDown(e: Event) {
- if (!this.isHidden) {
- e.preventDefault();
- e.stopPropagation();
- this.cursorDown();
- }
+ _handleDown() {
+ if (!this.isHidden) this.cursorDown();
}
cursorDown() {
- if (!this.isHidden) {
- this.cursor.next();
- }
+ if (!this.isHidden) this.cursor.next();
}
cursorUp() {
- if (!this.isHidden) {
- this.cursor.previous();
- }
+ if (!this.isHidden) this.cursor.previous();
}
- _handleTab(e: Event) {
- e.preventDefault();
- e.stopPropagation();
+ _handleTab() {
this.dispatchEvent(
new CustomEvent<ItemSelectedEvent>('item-selected', {
detail: {
@@ -184,9 +170,7 @@
);
}
- _handleEnter(e: Event) {
- e.preventDefault();
- e.stopPropagation();
+ _handleEnter() {
this.dispatchEvent(
new CustomEvent<ItemSelectedEvent>('item-selected', {
detail: {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 6d20279..6b8789e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -30,7 +30,8 @@
@customElement('gr-button')
export class GrButton extends LitElement {
- private readonly reporting = getAppContext().reportingService;
+ // Private but used in tests.
+ readonly reporting = getAppContext().reportingService;
/**
* Should this button be rendered as a vote chip? Then we are applying
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index a896382..00b8feb 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -19,7 +19,6 @@
import '../../../test/common-test-setup-karma';
import './gr-button';
import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {getAppContext} from '../../../services/app-context';
import {html} from '@polymer/polymer/lib/utils/html-tag';
import {GrButton} from './gr-button';
import {pressKey, queryAndAssert} from '../../../test/test-utils';
@@ -190,10 +189,7 @@
suite('reporting', () => {
let reportStub: sinon.SinonStub;
setup(() => {
- reportStub = sinon.stub(
- getAppContext().reportingService,
- 'reportInteraction'
- );
+ reportStub = sinon.stub(element.reporting, 'reportInteraction');
reportStub.reset();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 8251656..a895a5b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -19,259 +19,639 @@
import '../gr-comment/gr-comment';
import '../../diff/gr-diff/gr-diff';
import '../gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-thread_html';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, queryAll, state} from 'lit/decorators';
import {
computeDiffFromContext,
- computeId,
- DraftInfo,
isDraft,
isRobot,
- sortComments,
- UIComment,
- UIDraft,
- UIRobot,
+ Comment,
+ CommentThread,
+ getLastComment,
+ UnsavedInfo,
+ isDraftOrUnsaved,
+ createUnsavedComment,
+ getFirstComment,
+ createUnsavedReply,
+ isUnsaved,
} from '../../../utils/comment-util';
+import {ChangeMessageId} from '../../../api/rest-api';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getAppContext} from '../../../services/app-context';
import {
- CommentSide,
createDefaultDiffPrefs,
- Side,
SpecialFilePath,
} from '../../../constants/constants';
import {computeDisplayPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
import {
AccountDetailInfo,
CommentRange,
- ConfigInfo,
NumericChangeId,
- PatchSetNum,
RepoName,
UrlEncodedCommentId,
} from '../../../types/common';
import {GrComment} from '../gr-comment/gr-comment';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
+import {FILE} from '../../diff/gr-diff/gr-diff-line';
import {GrButton} from '../gr-button/gr-button';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {
- assertIsDefined,
- check,
- queryAndAssert,
-} from '../../../utils/common-util';
-import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire, fireAlert, waitForEventOnce} from '../../../utils/event-util';
import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
-import {StorageLocation} from '../../../services/storage/gr-storage';
import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
import {getUserName} from '../../../utils/display-name-util';
import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addGlobalShortcut} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {repeat} from 'lit/directives/repeat';
+import {classMap} from 'lit/directives/class-map';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
-const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
-export interface GrCommentThread {
- $: {
- replyBtn: GrButton;
- quoteBtn: GrButton;
- };
+declare global {
+ interface HTMLElementEventMap {
+ 'comment-thread-editing-changed': ValueChangedEvent<boolean>;
+ }
}
+/**
+ * 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 'FILE' if it refers to the entire file.
+ *
+ * diff-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.
+ */
@customElement('gr-comment-thread')
-export class GrCommentThread extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
+export class GrCommentThread extends LitElement {
+ @query('#replyBtn')
+ replyBtn?: GrButton;
+
+ @query('#quoteBtn')
+ quoteBtn?: GrButton;
+
+ @query('.comment-box')
+ commentBox?: HTMLElement;
+
+ @queryAll('gr-comment')
+ commentElements?: NodeList;
+
+ /** Required to be set by parent. */
+ @property()
+ thread?: CommentThread;
/**
- * gr-comment-thread exposes the following attributes that allow a
- * diff widget like gr-diff to show the thread in the right location:
+ * Id of the first comment and thus must not change. Will be derived from
+ * the `thread` property in the first willUpdate() cycle.
*
- * line-num:
- * 1-based line number or 'FILE' if it refers to the entire file.
+ * The `rootId` property is also used in gr-diff for maintaining lists and
+ * maps of threads and their associated elements.
*
- * diff-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.
+ * Only stays `undefined` for new threads that only have an unsaved comment.
*/
- @property({type: Number})
- changeNum?: NumericChangeId;
-
- @property({type: Array})
- comments: UIComment[] = [];
-
- @property({type: Object, reflectToAttribute: true})
- range?: CommentRange;
-
- @property({type: String, reflectToAttribute: true})
- diffSide?: Side;
-
@property({type: String})
- patchNum?: PatchSetNum;
-
- @property({type: String})
- path: string | undefined;
-
- @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?: UrlEncodedCommentId;
- @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+ // TODO: Is this attribute needed for querySelector() or css rules?
+ // We don't need this internally for the component.
+ @property({type: Boolean, reflect: true, attribute: 'has-draft'})
+ hasDraft?: boolean;
+
+ /** Will be inspected on firstUpdated() only. */
+ @property({type: Boolean, attribute: 'should-scroll-into-view'})
shouldScrollIntoView = false;
- @property({type: Boolean})
+ /**
+ * Should the file path and line number be rendered above the comment thread
+ * widget? Typically true in <gr-thread-list> and false in <gr-diff>.
+ */
+ @property({type: Boolean, attribute: 'show-file-path'})
showFilePath = false;
- @property({type: Object, reflectToAttribute: true})
- lineNum?: LineNumber;
+ /**
+ * Only relevant when `showFilePath` is set.
+ * If false, then only the line number is rendered.
+ */
+ @property({type: Boolean, attribute: 'show-file-name'})
+ showFileName = false;
- @property({type: Boolean, notify: true, reflectToAttribute: true})
- unresolved?: boolean;
+ @property({type: Boolean, attribute: 'show-ported-comment'})
+ showPortedComment = false;
- @property({type: Boolean})
- _showActions?: boolean;
+ /** This is set to false by <gr-diff>. */
+ @property({type: Boolean, attribute: false})
+ showPatchset = true;
- @property({type: Object})
- _lastComment?: UIComment;
+ @property({type: Boolean, attribute: 'show-comment-context'})
+ showCommentContext = false;
- @property({type: Array})
- _orderedComments: UIComment[] = [];
+ /**
+ * Optional context information when a thread is being displayed for a
+ * specific change message. That influences which comments are expanded or
+ * collapsed by default.
+ */
+ @property({type: String, attribute: 'message-id'})
+ messageId?: ChangeMessageId;
- @property({type: Object})
- _projectConfig?: ConfigInfo;
+ /**
+ * We are reflecting the editing state of the draft comment here. This is not
+ * an input property, but can be inspected from the parent component.
+ *
+ * Changes to this property are fired as 'comment-thread-editing-changed'
+ * events.
+ */
+ @property({type: Boolean, attribute: 'false'})
+ editing = false;
- @property({type: Object})
- _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+ /**
+ * This can either be an unsaved reply to the last comment or the unsaved
+ * content of a brand new comment thread (then `comments` is empty).
+ * If set, then `thread.comments` must not contain a draft. A thread can only
+ * contain *either* an unsaved comment *or* a draft, not both.
+ */
+ @state()
+ unsavedComment?: UnsavedInfo;
- @property({type: Object})
- _renderPrefs: RenderPreferences = {
+ @state()
+ changeNum?: NumericChangeId;
+
+ @state()
+ prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+ @state()
+ renderPrefs: RenderPreferences = {
hide_left_side: true,
disable_context_control_buttons: true,
show_file_comment_button: false,
hide_line_length_indicator: true,
};
- @property({type: Boolean, reflectToAttribute: true})
- isRobotComment = false;
+ @state()
+ repoName?: RepoName;
- @property({type: Boolean})
- showFileName = true;
+ @state()
+ account?: AccountDetailInfo;
- @property({type: Boolean})
- showPortedComment = false;
-
- @property({type: Boolean})
- showPatchset = true;
-
- @property({type: Boolean})
- showCommentContext = false;
-
- @property({type: Object})
- _selfAccount?: AccountDetailInfo;
-
- @property({type: Array})
+ @state()
layers: DiffLayer[] = [];
- @property({type: Object, computed: 'computeDiff(comments, path)'})
- _diff?: DiffInfo;
+ /** Computed during willUpdate(). */
+ @state()
+ diff?: DiffInfo;
- /** Called in disconnectedCallback. */
- private cleanups: (() => void)[] = [];
+ /** Computed during willUpdate(). */
+ @state()
+ highlightRange?: CommentRange;
- private readonly reporting = getAppContext().reportingService;
+ /**
+ * Reflects the *dirty* state of whether the thread is currently unresolved.
+ * We are listening on the <gr-comment> of the draft, so we even know when the
+ * checkbox is checked, even if not yet saved.
+ */
+ @state()
+ unresolved = true;
- private readonly commentsService = getAppContext().commentsService;
+ /**
+ * Normally drafts are saved within the <gr-comment> child component and we
+ * don't care about that. But when creating 'Done.' replies we are actually
+ * saving from this component. True while the REST API call is inflight.
+ */
+ @state()
+ saving = false;
- private readonly restApiService = getAppContext().restApiService;
+ private readonly commentsModel = getAppContext().commentsModel;
- private readonly shortcuts = getAppContext().shortcutsService;
+ private readonly changeModel = getAppContext().changeModel;
- readonly storage = getAppContext().storageService;
+ private readonly userModel = getAppContext().userModel;
+
+ private readonly shortcuts = new ShortcutController(this);
private readonly syntaxLayer = new GrSyntaxLayer();
constructor() {
super();
- this.addEventListener('comment-update', e =>
- this._handleCommentUpdate(e as CustomEvent)
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+ subscribe(this, this.userModel.account$, x => (this.account = x));
+ subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+ subscribe(this, this.userModel.diffPreferences$, x =>
+ this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
);
- this.restApiService.getPreferences().then(prefs => {
- this._initLayers(!!prefs?.disable_token_highlighting);
+ subscribe(this, this.userModel.preferences$, prefs => {
+ const layers: DiffLayer[] = [this.syntaxLayer];
+ if (!prefs.disable_token_highlighting) {
+ layers.push(new TokenHighlightLayer(this));
+ }
+ this.layers = layers;
});
- }
-
- override disconnectedCallback() {
- super.disconnectedCallback();
- for (const cleanup of this.cleanups) cleanup();
- this.cleanups = [];
- }
-
- override connectedCallback() {
- super.connectedCallback();
- this.cleanups.push(
- addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
- );
- this.cleanups.push(
- addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
- );
- this._getLoggedIn().then(loggedIn => {
- this._showActions = loggedIn;
- });
- this.restApiService.getDiffPreferences().then(prefs => {
- if (!prefs) return;
- this._prefs = {
+ subscribe(this, this.userModel.diffPreferences$, prefs => {
+ this.prefs = {
...prefs,
// set line_wrapping to true so that the context can take all the
// remaining space after comment card has rendered
line_wrapping: true,
};
- this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
});
- this.restApiService.getAccount().then(account => {
- this._selfAccount = account;
- });
- this._setInitialExpandedState();
+ this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
+ this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
}
- computeDiff(comments?: UIComment[], path?: string) {
- if (comments === undefined || path === undefined) return undefined;
- if (!comments[0]?.context_lines?.length) return undefined;
+ static override get styles() {
+ return [
+ a11yStyles,
+ sharedStyles,
+ css`
+ :host {
+ font-family: var(--font-family);
+ font-size: var(--font-size-normal);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--line-height-normal);
+ /* Explicitly set the background color of the diff. We
+ * cannot use the diff content type ab because of the skip chunk preceding
+ * it, diff processor assumes the chunk of type skip/ab can be collapsed
+ * and hides our diff behind context control buttons.
+ * */
+ --dark-add-highlight-color: var(--background-color-primary);
+ }
+ gr-button {
+ margin-left: var(--spacing-m);
+ }
+ gr-comment {
+ border-bottom: 1px solid var(--comment-separator-color);
+ }
+ #actions {
+ margin-left: auto;
+ padding: var(--spacing-s) var(--spacing-m);
+ }
+ .comment-box {
+ width: 80ch;
+ max-width: 100%;
+ background-color: var(--comment-background-color);
+ color: var(--comment-text-color);
+ box-shadow: var(--elevation-level-2);
+ border-radius: var(--border-radius);
+ flex-shrink: 0;
+ }
+ #container {
+ display: var(--gr-comment-thread-display, flex);
+ align-items: flex-start;
+ margin: 0 var(--spacing-s) var(--spacing-s);
+ white-space: normal;
+ /** This is required for firefox to continue the inheritance */
+ -webkit-user-select: inherit;
+ -moz-user-select: inherit;
+ -ms-user-select: inherit;
+ user-select: inherit;
+ }
+ .comment-box.unresolved {
+ background-color: var(--unresolved-comment-background-color);
+ }
+ .comment-box.robotComment {
+ background-color: var(--robot-comment-background-color);
+ }
+ #actionsContainer {
+ display: flex;
+ }
+ .comment-box.saving #actionsContainer {
+ opacity: 0.5;
+ }
+ #unresolvedLabel {
+ font-family: var(--font-family);
+ margin: auto 0;
+ padding: var(--spacing-m);
+ }
+ .pathInfo {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 0 var(--spacing-s) var(--spacing-s);
+ }
+ .fileName {
+ padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+ }
+ @media only screen and (max-width: 1200px) {
+ .diff-container {
+ display: none;
+ }
+ }
+ .diff-container {
+ margin-left: var(--spacing-l);
+ border: 1px solid var(--border-color);
+ flex-grow: 1;
+ flex-shrink: 1;
+ max-width: 1200px;
+ }
+ .view-diff-button {
+ margin: var(--spacing-s) var(--spacing-m);
+ }
+ .view-diff-container {
+ border-top: 1px solid var(--border-color);
+ background-color: var(--background-color-primary);
+ }
+
+ /* In saved state the "reply" and "quote" buttons are 28px height.
+ * top:4px positions the 20px icon vertically centered.
+ * Currently in draft state the "save" and "cancel" buttons are 20px
+ * height, so the link icon does not need a top:4px in gr-comment_html.
+ */
+ .link-icon {
+ position: relative;
+ top: 4px;
+ cursor: pointer;
+ }
+ .fileName gr-copy-clipboard {
+ display: inline-block;
+ visibility: hidden;
+ vertical-align: top;
+ --gr-button-padding: 0px;
+ }
+ .fileName:focus-within gr-copy-clipboard,
+ .fileName:hover gr-copy-clipboard {
+ visibility: visible;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (!this.thread) return;
+ const dynamicBoxClasses = {
+ robotComment: this.isRobotComment(),
+ unresolved: this.unresolved,
+ saving: this.saving,
+ };
+ return html`
+ ${this.renderFilePath()}
+ <div id="container">
+ <h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
+ <div class="comment-box ${classMap(dynamicBoxClasses)}" tabindex="0">
+ ${this.renderComments()} ${this.renderActions()}
+ </div>
+ ${this.renderContextualDiff()}
+ </div>
+ `;
+ }
+
+ renderFilePath() {
+ if (!this.showFilePath) return;
+ const href = this.getUrlForComment();
+ const line = this.computeDisplayLine();
+ return html`
+ ${this.renderFileName()}
+ <div class="pathInfo">
+ ${href
+ ? html`<a href="${href}">${line}</a>`
+ : html`<span>${line}</span>`}
+ </div>
+ `;
+ }
+
+ renderFileName() {
+ if (!this.showFileName) return;
+ if (this.isPatchsetLevel()) {
+ return html`<div class="fileName"><span>Patchset</span></div>`;
+ }
+ const href = this.getDiffUrlForPath();
+ const displayPath = this.getDisplayPath();
+ return html`
+ <div class="fileName">
+ ${href
+ ? html`<a href="${href}">${displayPath}</a>`
+ : html`<span>${displayPath}</span>`}
+ <gr-copy-clipboard hideInput .text="${displayPath}"></gr-copy-clipboard>
+ </div>
+ `;
+ }
+
+ renderComments() {
+ assertIsDefined(this.thread, 'thread');
+ const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+ const comments: Comment[] = [...this.thread.comments];
+ if (this.unsavedComment && !this.isDraft()) {
+ comments.push(this.unsavedComment);
+ }
+ return repeat(
+ comments,
+ // We want to reuse <gr-comment> when unsaved changes to draft.
+ comment => (isDraftOrUnsaved(comment) ? 'unsaved' : comment.id),
+ comment => {
+ const initiallyCollapsed =
+ !isDraftOrUnsaved(comment) &&
+ (this.messageId
+ ? comment.change_message_id !== this.messageId
+ : !this.unresolved);
+ return html`
+ <gr-comment
+ .comment="${comment}"
+ .comments="${this.thread!.comments}"
+ .patchNum="${this.thread?.patchNum}"
+ ?initially-collapsed="${initiallyCollapsed}"
+ ?robot-button-disabled="${robotButtonDisabled}"
+ ?show-patchset="${this.showPatchset}"
+ ?show-ported-comment="${this.showPortedComment &&
+ comment.id === this.rootId}"
+ @create-fix-comment="${this.handleCommentFix}"
+ @copy-comment-link="${this.handleCopyLink}"
+ @comment-editing-changed="${(e: CustomEvent) => {
+ if (isDraftOrUnsaved(comment)) this.editing = e.detail;
+ }}"
+ @comment-unresolved-changed="${(e: CustomEvent) => {
+ if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
+ }}"
+ ></gr-comment>
+ `;
+ }
+ );
+ }
+
+ renderActions() {
+ if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
+ return;
+ return html`
+ <div id="actionsContainer">
+ <span id="unresolvedLabel">${
+ this.unresolved ? 'Unresolved' : 'Resolved'
+ }</span>
+ <div id="actions">
+ <iron-icon
+ class="link-icon copy"
+ @click="${this.handleCopyLink}"
+ title="Copy link to this comment"
+ icon="gr-icons:link"
+ role="button"
+ tabindex="0"
+ >
+ </iron-icon>
+ <gr-button
+ id="replyBtn"
+ link
+ class="action reply"
+ ?disabled="${this.saving}"
+ @click="${() => this.handleCommentReply(false)}"
+ >Reply</gr-button
+ >
+ <gr-button
+ id="quoteBtn"
+ link
+ class="action quote"
+ ?disabled="${this.saving}"
+ @click="${() => this.handleCommentReply(true)}"
+ >Quote</gr-button
+ >
+ ${
+ this.unresolved
+ ? html`
+ <gr-button
+ id="ackBtn"
+ link
+ class="action ack"
+ ?disabled="${this.saving}"
+ @click="${this.handleCommentAck}"
+ >Ack</gr-button
+ >
+ <gr-button
+ id="doneBtn"
+ link
+ class="action done"
+ ?disabled="${this.saving}"
+ @click="${this.handleCommentDone}"
+ >Done</gr-button
+ >
+ `
+ : ''
+ }
+ </div>
+ </div>
+ </div>
+ `;
+ }
+
+ renderContextualDiff() {
+ if (!this.changeNum || !this.showCommentContext || !this.diff) return;
+ if (!this.thread?.path) return;
+ const href = this.getUrlForComment();
+ return html`
+ <div class="diff-container">
+ <gr-diff
+ id="diff"
+ .changeNum="${this.changeNum}"
+ .diff="${this.diff}"
+ .layers="${this.layers}"
+ .path="${this.thread.path}"
+ .prefs="${this.prefs}"
+ .renderPrefs="${this.renderPrefs}"
+ .highlightRange="${this.highlightRange}"
+ >
+ </gr-diff>
+ <div class="view-diff-container">
+ <a href="${href}">
+ <gr-button link class="view-diff-button">View Diff</gr-button>
+ </a>
+ </div>
+ </div>
+ `;
+ }
+
+ private firstWillUpdateDone = false;
+
+ firstWillUpdate() {
+ if (!this.thread) return;
+ if (this.firstWillUpdateDone) return;
+ this.firstWillUpdateDone = true;
+
+ if (this.getFirstComment() === undefined) {
+ this.unsavedComment = createUnsavedComment(this.thread);
+ }
+ this.unresolved = this.getLastComment()?.unresolved ?? true;
+ this.diff = this.computeDiff();
+ this.highlightRange = this.computeHighlightRange();
+ }
+
+ override willUpdate(changed: PropertyValues) {
+ this.firstWillUpdate();
+ if (changed.has('thread')) {
+ if (!this.isDraftOrUnsaved()) {
+ // We can only do this for threads without draft, because otherwise we
+ // are relying on the <gr-comment> component for the draft to fire
+ // events about the *dirty* `unresolved` state.
+ this.unresolved = this.getLastComment()?.unresolved ?? true;
+ }
+ this.hasDraft = this.isDraftOrUnsaved();
+ this.rootId = this.getFirstComment()?.id;
+ if (this.isDraft()) {
+ this.unsavedComment = undefined;
+ }
+ }
+ if (changed.has('editing')) {
+ // changed.get('editing') contains the old value. We only want to trigger
+ // when changing from editing to non-editing (user has cancelled/saved).
+ // We do *not* want to trigger on first render (old value is `null`)
+ if (!this.editing && changed.get('editing') === true) {
+ this.unsavedComment = undefined;
+ if (this.thread?.comments.length === 0) {
+ this.remove();
+ }
+ }
+ fire(this, 'comment-thread-editing-changed', {value: this.editing});
+ }
+ }
+
+ override firstUpdated() {
+ if (this.shouldScrollIntoView) {
+ this.commentBox?.focus();
+ this.scrollIntoView();
+ }
+ }
+
+ private isDraft() {
+ return isDraft(this.getLastComment());
+ }
+
+ private isDraftOrUnsaved(): boolean {
+ return this.isDraft() || this.isUnsaved();
+ }
+
+ private isNewThread(): boolean {
+ return this.thread?.comments.length === 0;
+ }
+
+ private isUnsaved(): boolean {
+ return !!this.unsavedComment || this.thread?.comments.length === 0;
+ }
+
+ private isPatchsetLevel() {
+ return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+ }
+
+ private computeDiff() {
+ if (!this.showCommentContext) return;
+ if (!this.thread?.path) return;
+ const firstComment = this.getFirstComment();
+ if (!firstComment?.context_lines?.length) return;
const diff = computeDiffFromContext(
- comments[0].context_lines,
- path,
- comments[0].source_content_type
+ firstComment.context_lines,
+ this.thread?.path,
+ firstComment.source_content_type
);
// Do we really have to re-compute (and re-render) the diff?
- if (this._diff && JSON.stringify(this._diff) === JSON.stringify(diff)) {
- return this._diff;
+ if (this.diff && JSON.stringify(this.diff) === JSON.stringify(diff)) {
+ return this.diff;
}
if (!anyLineTooLong(diff)) {
@@ -283,83 +663,21 @@
return diff;
}
- handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
- // Wait for comment to be rendered before scrolling to it
- if (shouldScrollIntoView) {
- const resizeObserver = new ResizeObserver(
- (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
- if (this.offsetHeight > 0) {
- queryAndAssert<HTMLDivElement>(this, '.comment-box').focus();
- this.scrollIntoView();
- }
- observer.unobserve(this);
- }
- );
- resizeObserver.observe(this);
+ private getDiffUrlForPath() {
+ if (!this.changeNum || !this.repoName || !this.thread?.path) {
+ return undefined;
}
+ if (this.isNewThread()) return undefined;
+ return GerritNav.getUrlForDiffById(
+ this.changeNum,
+ this.repoName,
+ this.thread.path,
+ this.thread.patchNum
+ );
}
- _shouldShowCommentContext(
- changeNum?: NumericChangeId,
- showCommentContext?: boolean,
- diff?: DiffInfo
- ) {
- return changeNum && showCommentContext && !!diff;
- }
-
- addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
- const lastComment = this.comments[this.comments.length - 1] || {};
- if (isDraft(lastComment)) {
- 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?: LineNumber, range?: CommentRange, unresolved?: boolean) {
- const draft = this._newDraft(lineNum, range);
- draft.__editing = true;
- draft.unresolved = unresolved === false ? unresolved : true;
- this.commentsService.addDraft(draft);
- }
-
- _getDiffUrlForPath(
- projectName?: RepoName,
- changeNum?: NumericChangeId,
- path?: string,
- patchNum?: PatchSetNum
- ) {
- if (!changeNum || !projectName || !path) return undefined;
- if (isDraft(this.comments[0])) {
- return GerritNav.getUrlForDiffById(
- changeNum,
- projectName,
- path,
- patchNum
- );
- }
- const id = this.comments[0].id;
- if (!id) throw new Error('A published comment is missing the id.');
- return GerritNav.getUrlForComment(changeNum, projectName, id);
- }
-
- /** The parameter is for triggering re-computation only. */
- getHighlightRange(_: unknown) {
- const comment = this.comments?.[0];
+ private computeHighlightRange() {
+ const comment = this.getFirstComment();
if (!comment) return undefined;
if (comment.range) return comment.range;
if (comment.line) {
@@ -373,413 +691,130 @@
return undefined;
}
- _initLayers(disableTokenHighlighting: boolean) {
- if (!disableTokenHighlighting) {
- this.layers.push(new TokenHighlightLayer(this));
+ private getUrlForComment() {
+ if (!this.repoName || !this.changeNum || this.isNewThread()) {
+ return undefined;
}
- this.layers.push(this.syntaxLayer);
- }
-
- _getUrlForViewDiff(
- comments: UIComment[],
- changeNum?: NumericChangeId,
- projectName?: RepoName
- ): string {
- if (!changeNum) return '';
- if (!projectName) return '';
- check(comments.length > 0, 'comment not found');
- return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
- }
-
- _getDiffUrlForComment(
- projectName?: RepoName,
- changeNum?: NumericChangeId,
- path?: string,
- patchNum?: PatchSetNum
- ) {
- if (!projectName || !changeNum || !path) return undefined;
- if (
- (this.comments.length && this.comments[0].side === 'PARENT') ||
- isDraft(this.comments[0])
- ) {
- if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
- return GerritNav.getUrlForDiffById(
- changeNum,
- projectName,
- path,
- patchNum,
- undefined,
- this.lineNum === FILE ? undefined : this.lineNum
- );
- }
- const id = this.comments[0].id;
- if (!id) throw new Error('A published comment is missing the id.');
- return GerritNav.getUrlForComment(changeNum, projectName, id);
- }
-
- handleCopyLink() {
- assertIsDefined(this.changeNum, 'changeNum');
- assertIsDefined(this.projectName, 'projectName');
- const url = generateAbsoluteUrl(
- GerritNav.getUrlForCommentsTab(
- this.changeNum,
- this.projectName,
- this.comments[0].id!
- )
+ assertIsDefined(this.rootId, 'rootId of comment thread');
+ return GerritNav.getUrlForComment(
+ this.changeNum,
+ this.repoName,
+ this.rootId
);
- navigator.clipboard.writeText(url).then(() => {
+ }
+
+ private handleCopyLink() {
+ const url = this.getUrlForComment();
+ assertIsDefined(url, 'url for comment');
+ navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
fireAlert(this, 'Link copied to clipboard');
});
}
- _isPatchsetLevelComment(path?: string) {
- return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+ private getDisplayPath() {
+ if (this.isPatchsetLevel()) return 'Patchset';
+ return computeDisplayPath(this.thread?.path);
}
- _computeShowPortedComment(comment: UIComment) {
- if (this._orderedComments.length === 0) return false;
- return this.showPortedComment && comment.id === this._orderedComments[0].id;
- }
-
- _computeDisplayPath(path?: string) {
- const displayPath = computeDisplayPath(path);
- if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return 'Patchset';
- }
- return displayPath;
- }
-
- _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
- if (lineNum === FILE) {
- if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return '';
- }
- return FILE;
- }
- if (lineNum) return `#${lineNum}`;
+ private computeDisplayLine() {
+ assertIsDefined(this.thread, 'thread');
+ if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE;
+ if (this.thread.line) return `#${this.thread.line}`;
// If range is set, then lineNum equals the end line of the range.
- if (range) return `#${range.end_line}`;
+ if (this.thread.range) return `#${this.thread.range.end_line}`;
return '';
}
- _getLoggedIn() {
- return this.restApiService.getLoggedIn();
+ private isRobotComment() {
+ return isRobot(this.getLastComment());
}
- _getUnresolvedLabel(unresolved?: boolean) {
- return unresolved ? 'Unresolved' : 'Resolved';
+ private getFirstComment() {
+ assertIsDefined(this.thread);
+ return getFirstComment(this.thread);
}
- @observe('comments.*')
- _commentsChanged() {
- this._orderedComments = sortComments(this.comments);
- this.updateThreadProperties();
+ private getLastComment() {
+ assertIsDefined(this.thread);
+ return getLastComment(this.thread);
}
- updateThreadProperties() {
- if (this._orderedComments.length) {
- this._lastComment = this._getLastComment();
- this.unresolved = this._lastComment.unresolved;
- this.hasDraft = isDraft(this._lastComment);
- this.isRobotComment = isRobot(this._lastComment);
+ private handleExpandShortcut() {
+ this.expandCollapseComments(false);
+ }
+
+ private handleCollapseShortcut() {
+ this.expandCollapseComments(true);
+ }
+
+ private expandCollapseComments(actionIsCollapse: boolean) {
+ for (const comment of this.commentElements ?? []) {
+ (comment as GrComment).collapsed = actionIsCollapse;
}
}
- _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
- return !_showActions || !_lastComment || isDraft(_lastComment);
- }
-
- _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
- return (
- this._shouldDisableAction(_showActions, _lastComment) ||
- isRobot(_lastComment)
- );
- }
-
- _getLastComment() {
- return this._orderedComments[this._orderedComments.length - 1] || {};
- }
-
- private handleExpandShortcut(e: KeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- this._expandCollapseComments(false);
- }
-
- private handleCollapseShortcut(e: KeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- this._expandCollapseComments(true);
- }
-
- _expandCollapseComments(actionIsCollapse: boolean) {
- const comments = this.root?.querySelectorAll('gr-comment');
- if (!comments) return;
- for (const comment of comments) {
- comment.collapsed = actionIsCollapse;
+ private async createReplyComment(
+ content: string,
+ userWantsToEdit: boolean,
+ unresolved: boolean
+ ) {
+ const replyingTo = this.getLastComment();
+ assertIsDefined(this.thread, 'thread');
+ assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
+ if (isDraft(replyingTo)) {
+ throw new Error('cannot reply to draft');
}
- }
-
- /**
- * 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.
- * - it's a draft
- */
- _setInitialExpandedState() {
- if (this._orderedComments) {
- for (let i = 0; i < this._orderedComments.length; i++) {
- const comment = this._orderedComments[i];
- if (isDraft(comment)) {
- comment.collapsed = false;
- continue;
- }
- const isRobotComment = !!(comment as UIRobot).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;
- }
+ if (isUnsaved(replyingTo)) {
+ throw new Error('cannot reply to unsaved comment');
+ }
+ const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+ if (userWantsToEdit) {
+ this.unsavedComment = unsaved;
+ } else {
+ try {
+ this.saving = true;
+ await this.commentsModel.saveDraft(unsaved);
+ } finally {
+ this.saving = false;
}
}
}
- _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 (isEditing) {
- reply.__editing = true;
- this.commentsService.addDraft(reply);
- } else {
- assertIsDefined(this.changeNum, 'changeNum');
- assertIsDefined(this.patchNum, 'patchNum');
- this.restApiService
- .saveDiffDraft(this.changeNum, this.patchNum, reply)
- .then(result => {
- if (!result.ok) {
- fireAlert(document, 'Unable to restore draft');
- return;
- }
- this.restApiService.getResponseObject(result).then(obj => {
- const resComment = obj as unknown as DraftInfo;
- resComment.patch_set = reply.patch_set;
- this.commentsService.addDraft(resComment);
- });
- });
- }
- }
-
- _isDraft(comment: UIComment) {
- return isDraft(comment);
- }
-
- _processCommentReply(quote?: boolean) {
- const comment = this._lastComment;
+ private handleCommentReply(quote: boolean) {
+ const comment = this.getLastComment();
if (!comment) throw new Error('Failed to find last comment.');
- let content = undefined;
+ let content = '';
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);
+ this.createReplyComment(content, true, comment.unresolved ?? true);
}
- _handleCommentReply() {
- this._processCommentReply();
+ private handleCommentAck() {
+ this.createReplyComment('Ack', false, false);
}
- _handleCommentQuote() {
- this._processCommentReply(true);
+ private handleCommentDone() {
+ this.createReplyComment('Done', false, false);
}
- _handleCommentAck() {
- this._createReplyComment('Ack', false, false);
- }
-
- _handleCommentDone() {
- this._createReplyComment('Done', false, false);
- }
-
- _handleCommentFix(e: CustomEvent) {
+ private 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);
+ 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) {
- const c = el.comment;
- if (isRobot(c)) continue;
- if (c?.id === id || (isDraft(c) && c?.__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?: LineNumber, range?: CommentRange) {
- const d: UIDraft = {
- __draft: true,
- __draftID: 'draft__' + Math.random().toString(36),
- __date: new Date(),
- };
- if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
- // 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.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);
-
- if (lineNum && lineNum !== FILE) {
- 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<UIComment[], 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;
- }
- return computeId(comments.base[0]);
- }
-
- _handleCommentDiscard() {
- assertIsDefined(this.changeNum, 'changeNum');
- assertIsDefined(this.patchNum, 'patchNum');
- // 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 (isDraft(changeComment) && changeComment.__editing) {
- const commentLocation: StorageLocation = {
- changeNum: this.changeNum,
- patchNum: this.patchNum,
- path: changeComment.path,
- line: changeComment.line,
- };
- this.storage.setDraftComment(
- commentLocation,
- changeComment.message ?? ''
- );
- }
- }
- }
-
- _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.
- this.reporting.error(
- new Error(`Comment update for another comment thread: ${comment}`)
- );
- 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: UIComment | undefined, arr: UIComment[]) {
- if (!comment) return -1;
- for (let i = 0; i < arr.length; i++) {
- const c = arr[i];
- if (
- (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
- (c.id && c.id === comment.id)
- ) {
- return i;
- }
- }
- return -1;
- }
-
- /** 2nd parameter is for triggering re-computation only. */
- _computeHostClass(unresolved?: boolean, _?: unknown) {
- 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.restApiService.getProjectConfig(name).then(config => {
- this._projectConfig = config;
- });
- }
-
- _computeAriaHeading(_orderedComments: UIComment[]) {
- const firstComment = _orderedComments[0];
- const author = firstComment?.author ?? this._selfAccount;
- const lastComment = _orderedComments[_orderedComments.length - 1] || {};
- const status = [
- lastComment.unresolved ? 'Unresolved' : '',
- isDraft(lastComment) ? 'Draft' : '',
- ].join(' ');
- return `${status} Comment thread by ${getUserName(undefined, author)}`;
+ private computeAriaHeading() {
+ const author = this.getFirstComment()?.author ?? this.account;
+ const user = getUserName(undefined, author);
+ const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
+ const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+ return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
deleted file mode 100644
index c3faaa5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="gr-a11y-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <style include="shared-styles">
- :host {
- font-family: var(--font-family);
- font-size: var(--font-size-normal);
- font-weight: var(--font-weight-normal);
- line-height: var(--line-height-normal);
- /* Explicitly set the background color of the diff. We
- * cannot use the diff content type ab because of the skip chunk preceding
- * it, diff processor assumes the chunk of type skip/ab can be collapsed
- * and hides our diff behind context control buttons.
- * */
- --dark-add-highlight-color: var(--background-color-primary);
- }
- gr-button {
- margin-left: var(--spacing-m);
- }
- gr-comment {
- border-bottom: 1px solid var(--comment-separator-color);
- }
- #actions {
- margin-left: auto;
- padding: var(--spacing-s) var(--spacing-m);
- }
- .comment-box {
- width: 80ch;
- max-width: 100%;
- background-color: var(--comment-background-color);
- color: var(--comment-text-color);
- box-shadow: var(--elevation-level-2);
- border-radius: var(--border-radius);
- flex-shrink: 0;
- }
- #container {
- display: var(--gr-comment-thread-display, flex);
- align-items: flex-start;
- margin: 0 var(--spacing-s) var(--spacing-s);
- white-space: normal;
- /** This is required for firefox to continue the inheritance */
- -webkit-user-select: inherit;
- -moz-user-select: inherit;
- -ms-user-select: inherit;
- user-select: inherit;
- }
- .comment-box.unresolved {
- background-color: var(--unresolved-comment-background-color);
- }
- .comment-box.robotComment {
- background-color: var(--robot-comment-background-color);
- }
- #commentInfoContainer {
- display: flex;
- }
- #unresolvedLabel {
- font-family: var(--font-family);
- margin: auto 0;
- padding: var(--spacing-m);
- }
- .pathInfo {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- padding: 0 var(--spacing-s) var(--spacing-s);
- }
- .fileName {
- padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
- }
- @media only screen and (max-width: 1200px) {
- .diff-container {
- display: none;
- }
- }
- .diff-container {
- margin-left: var(--spacing-l);
- border: 1px solid var(--border-color);
- flex-grow: 1;
- flex-shrink: 1;
- max-width: 1200px;
- }
- .view-diff-button {
- margin: var(--spacing-s) var(--spacing-m);
- }
- .view-diff-container {
- border-top: 1px solid var(--border-color);
- background-color: var(--background-color-primary);
- }
-
- /* In saved state the "reply" and "quote" buttons are 28px height.
- * top:4px positions the 20px icon vertically centered.
- * Currently in draft state the "save" and "cancel" buttons are 20px
- * height, so the link icon does not need a top:4px in gr-comment_html.
- */
- .link-icon {
- position: relative;
- top: 4px;
- cursor: pointer;
- }
- .fileName gr-copy-clipboard {
- display: inline-block;
- visibility: hidden;
- vertical-align: top;
- --gr-button-padding: 0px;
- }
- .fileName:focus-within gr-copy-clipboard,
- .fileName:hover gr-copy-clipboard {
- visibility: visible;
- }
- </style>
-
- <template is="dom-if" if="[[showFilePath]]">
- <template is="dom-if" if="[[showFileName]]">
- <div class="fileName">
- <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
- <span> [[_computeDisplayPath(path)]] </span>
- </template>
- <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
- <a
- href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
- >
- [[_computeDisplayPath(path)]]
- </a>
- <gr-copy-clipboard
- hideInput=""
- text="[[_computeDisplayPath(path)]]"
- ></gr-copy-clipboard>
- </template>
- </div>
- </template>
- <div class="pathInfo">
- <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
- <a
- href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
- >[[_computeDisplayLine(lineNum, range)]]</a
- >
- </template>
- </div>
- </template>
- <div id="container">
- <h3 class="assistive-tech-only">
- [[_computeAriaHeading(_orderedComments)]]
- </h3>
- <div
- class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box"
- tabindex="0"
- >
- <template
- id="commentList"
- is="dom-repeat"
- items="[[_orderedComments]]"
- as="comment"
- >
- <gr-comment
- comment="{{comment}}"
- comments="{{comments}}"
- robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
- change-num="[[changeNum]]"
- project-name="[[projectName]]"
- patch-num="[[patchNum]]"
- draft="[[_isDraft(comment)]]"
- show-actions="[[_showActions]]"
- show-patchset="[[showPatchset]]"
- show-ported-comment="[[_computeShowPortedComment(comment)]]"
- side="[[comment.side]]"
- project-config="[[_projectConfig]]"
- on-create-fix-comment="_handleCommentFix"
- on-comment-discard="_handleCommentDiscard"
- on-copy-comment-link="handleCopyLink"
- ></gr-comment>
- </template>
- <div
- id="commentInfoContainer"
- hidden$="[[_hideActions(_showActions, _lastComment)]]"
- >
- <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
- <div id="actions">
- <iron-icon
- class="link-icon"
- on-click="handleCopyLink"
- class="copy"
- title="Copy link to this comment"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- >
- </iron-icon>
- <gr-button
- id="replyBtn"
- link=""
- class="action reply"
- on-click="_handleCommentReply"
- >Reply</gr-button
- >
- <gr-button
- id="quoteBtn"
- link=""
- class="action quote"
- on-click="_handleCommentQuote"
- >Quote</gr-button
- >
- <template is="dom-if" if="[[unresolved]]">
- <gr-button
- id="ackBtn"
- link=""
- class="action ack"
- on-click="_handleCommentAck"
- >Ack</gr-button
- >
- <gr-button
- id="doneBtn"
- link=""
- class="action done"
- on-click="_handleCommentDone"
- >Done</gr-button
- >
- </template>
- </div>
- </div>
- </div>
- <template
- is="dom-if"
- if="[[_shouldShowCommentContext(changeNum, showCommentContext, _diff)]]"
- >
- <div class="diff-container">
- <gr-diff
- id="diff"
- change-num="[[changeNum]]"
- diff="[[_diff]]"
- layers="[[layers]]"
- path="[[path]]"
- prefs="[[_prefs]]"
- render-prefs="[[_renderPrefs]]"
- highlight-range="[[getHighlightRange(comments)]]"
- >
- </gr-diff>
- <div class="view-diff-container">
- <a href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
- <gr-button link class="view-diff-button">View Diff</gr-button>
- </a>
- </div>
- </div>
- </template>
- </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index a4664ee..347e1e0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -14,1005 +14,361 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import '../../../test/common-test-setup-karma';
import './gr-comment-thread';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {SpecialFilePath, Side} from '../../../constants/constants';
-import {
- sortComments,
- UIComment,
- UIRobot,
- UIDraft,
-} from '../../../utils/comment-util';
+import {sortComments} from '../../../utils/comment-util';
import {GrCommentThread} from './gr-comment-thread';
import {
- PatchSetNum,
NumericChangeId,
UrlEncodedCommentId,
Timestamp,
- RobotId,
- RobotRunId,
+ CommentInfo,
RepoName,
- ConfigInfo,
- EmailAddress,
} from '../../../types/common';
-import {GrComment} from '../gr-comment/gr-comment';
-import {LineNumber} from '../../diff/gr-diff/gr-diff-line';
-import {
- tap,
- pressAndReleaseKeyOn,
-} from '@polymer/iron-test-helpers/mock-interactions';
import {
mockPromise,
+ queryAndAssert,
stubComments,
- stubReporting,
stubRestApi,
+ waitUntilCalled,
+ MockPromise,
} from '../../../test/test-utils';
-import {createComment} from '../../../test/test-data-generators';
+import {
+ createAccountDetailWithId,
+ createThread,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
import {SinonStub} from 'sinon';
+import {waitUntil} from '@open-wc/testing-helpers';
const basicFixture = fixtureFromElement('gr-comment-thread');
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
+const c1 = {
+ author: {name: 'Kermit'},
+ id: 'the-root' as UrlEncodedCommentId,
+ message: 'start the conversation',
+ updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const c2 = {
+ author: {name: 'Ms Piggy'},
+ id: 'the-reply' as UrlEncodedCommentId,
+ message: 'keep it going',
+ updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+ in_reply_to: 'the-root' as UrlEncodedCommentId,
+};
+
+const c3 = {
+ author: {name: 'Kermit'},
+ id: 'the-draft' as UrlEncodedCommentId,
+ message: 'stop it',
+ updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+ in_reply_to: 'the-reply' as UrlEncodedCommentId,
+ __draft: true,
+};
+
+const commentWithContext = {
+ author: {name: 'Kermit'},
+ id: 'the-draft' as UrlEncodedCommentId,
+ message: 'just for context',
+ updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+ line: 5,
+ context_lines: [
+ {line_number: 4, context_line: 'content of line 4'},
+ {line_number: 5, context_line: 'content of line 5'},
+ {line_number: 6, context_line: 'content of line 6'},
+ ],
+};
suite('gr-comment-thread tests', () => {
- suite('basic test', () => {
- let element: GrCommentThread;
+ let element: GrCommentThread;
- setup(async () => {
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- element = basicFixture.instantiate();
- element.patchNum = 3 as PatchSetNum;
- element.changeNum = 1 as NumericChangeId;
- await flush();
- });
+ setup(async () => {
+ stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+ element = basicFixture.instantiate();
+ element.changeNum = 1 as NumericChangeId;
+ element.showFileName = true;
+ element.showFilePath = true;
+ element.repoName = 'test-repo-name' as RepoName;
+ await element.updateComplete;
+ element.account = {...createAccountDetailWithId(13), name: 'Yoda'};
+ });
- test('renders', async () => {
- element.comments = [
- {
- ...createComment(),
- author: {name: 'Kermit'},
- id: 'the-root' as UrlEncodedCommentId,
- message: 'start the conversation',
- updated: '2021-11-01 10:11:12.000000000' as Timestamp,
- },
- {
- ...createComment(),
- author: {name: 'Ms Piggy'},
- id: 'the-reply' as UrlEncodedCommentId,
- message: 'keep it going',
- updated: '2021-11-02 10:11:12.000000000' as Timestamp,
- in_reply_to: 'the-root' as UrlEncodedCommentId,
- },
- {
- ...createComment(),
- author: {name: 'Kermit'},
- id: 'the-draft' as UrlEncodedCommentId,
- message: 'stop it',
- updated: '2021-11-03 10:11:12.000000000' as Timestamp,
- in_reply_to: 'the-reply' as UrlEncodedCommentId,
- __draft: true,
- },
- ];
- await flush();
- expect(element).shadowDom.to.equal(`
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ test('renders with draft', async () => {
+ element.thread = createThread(c1, c2, c3);
+ await element.updateComplete;
+ });
+
+ test('renders with draft', async () => {
+ element.thread = createThread(c1, c2, c3);
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="fileName">
+ <span>test-path-comment-thread</span>
+ <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+ </div>
+ <div class="pathInfo">
+ <span>#314</span>
+ </div>
<div id="container">
<h3 class="assistive-tech-only">Draft Comment thread by Kermit</h3>
<div class="comment-box" tabindex="0">
- <gr-comment></gr-comment>
- <gr-comment></gr-comment>
- <gr-comment></gr-comment>
- <dom-repeat as="comment" id="commentList" style="display: none;">
- <template is="dom-repeat"></template>
- </dom-repeat>
- <div hidden="true" id="commentInfoContainer">
- <span id="unresolvedLabel">Resolved</span>
+ <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+ <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+ <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders unsaved', async () => {
+ element.thread = createThread();
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="fileName">
+ <span>test-path-comment-thread</span>
+ <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+ </div>
+ <div class="pathInfo">
+ <span>#314</span>
+ </div>
+ <div id="container">
+ <h3 class="assistive-tech-only">Unresolved Draft Comment thread by Yoda</h3>
+ <div class="comment-box unresolved" tabindex="0">
+ <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders with actions resolved', async () => {
+ element.thread = createThread(c1, c2);
+ await element.updateComplete;
+ expect(queryAndAssert(element, '#container')).dom.to.equal(`
+ <div id="container">
+ <h3 class="assistive-tech-only">Comment thread by Kermit</h3>
+ <div class="comment-box" tabindex="0">
+ <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+ <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+ <div id="actionsContainer">
+ <span id="unresolvedLabel">
+ Resolved
+ </span>
<div id="actions">
- <iron-icon
- class="link-icon"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- title="Copy link to this comment"
- >
+ <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
</iron-icon>
- <gr-button
- aria-disabled="false"
- class="action reply"
- id="replyBtn"
- link=""
- role="button"
- tabindex="0"
- >
+ <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
Reply
</gr-button>
- <gr-button
- aria-disabled="false"
- class="action quote"
- id="quoteBtn"
- link=""
- role="button"
- tabindex="0"
- >
+ <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
Quote
</gr-button>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
</div>
</div>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
</div>
`);
- });
-
- test('comments are sorted correctly', () => {
- const comments: UIComment[] = [
- {
- message: 'i like you, too',
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- __date: new Date('2015-12-25'),
- },
- {
- id: 'sallys_confession' as UrlEncodedCommentId,
- message: 'i like you, jack',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i’m running away',
- updated: '2015-10-31 09:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_defiance' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i will poison you so i can get away',
- updated: '2015-10-31 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'dr_finklesteins_response' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'no i will pull a thread and your arm will fall off',
- updated: '2015-10-31 11:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_mission' as UrlEncodedCommentId,
- message: 'i have to find santa',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- ];
- const results = sortComments(comments);
- assert.deepEqual(results, [
- {
- id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i’m running away',
- updated: '2015-10-31 09:00:20.396000000' as Timestamp,
- },
- {
- id: 'dr_finklesteins_response' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'no i will pull a thread and your arm will fall off',
- updated: '2015-10-31 11:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_defiance' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i will poison you so i can get away',
- updated: '2015-10-31 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_confession' as UrlEncodedCommentId,
- message: 'i like you, jack',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_mission' as UrlEncodedCommentId,
- message: 'i have to find santa',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- {
- message: 'i like you, too' as UrlEncodedCommentId,
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- __date: new Date('2015-12-25'),
- },
- ]);
- });
-
- test('addOrEditDraft w/ edit draft', () => {
- element.comments = [
- {
- id: 'jacks_reply' as UrlEncodedCommentId,
- message: 'i like you, too',
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- updated: '2015-12-25 15:00:20.396000000' as Timestamp,
- __draft: true,
- },
- ];
- const commentElStub = sinon
- .stub(element, '_commentElWithDraftID')
- .callsFake(() => new GrComment());
- const addDraftStub = sinon.stub(element, 'addDraft');
-
- element.addOrEditDraft(123);
-
- assert.isTrue(commentElStub.called);
- assert.isFalse(addDraftStub.called);
- });
-
- test('addOrEditDraft w/o edit draft', () => {
- element.comments = [];
- const commentElStub = sinon
- .stub(element, '_commentElWithDraftID')
- .callsFake(() => new GrComment());
- const addDraftStub = sinon.stub(element, 'addDraft');
-
- element.addOrEditDraft(123);
-
- assert.isFalse(commentElStub.called);
- assert.isTrue(addDraftStub.called);
- });
-
- test('_shouldDisableAction', () => {
- let showActions = true;
- const lastComment: UIComment = {};
- assert.equal(
- element._shouldDisableAction(showActions, lastComment),
- false
- );
- showActions = false;
- assert.equal(
- element._shouldDisableAction(showActions, lastComment),
- true
- );
- showActions = true;
- lastComment.__draft = true;
- assert.equal(
- element._shouldDisableAction(showActions, lastComment),
- true
- );
- const robotComment: UIRobot = {
- id: '1234' as UrlEncodedCommentId,
- updated: '1234' as Timestamp,
- robot_id: 'robot_id' as RobotId,
- robot_run_id: 'robot_run_id' as RobotRunId,
- properties: {},
- fix_suggestions: [],
- };
- assert.equal(
- element._shouldDisableAction(showActions, robotComment),
- false
- );
- });
-
- test('_hideActions', () => {
- let showActions = true;
- const lastComment: UIComment = {};
- assert.equal(element._hideActions(showActions, lastComment), false);
- showActions = false;
- assert.equal(element._hideActions(showActions, lastComment), true);
- showActions = true;
- lastComment.__draft = true;
- assert.equal(element._hideActions(showActions, lastComment), true);
- const robotComment: UIRobot = {
- id: '1234' as UrlEncodedCommentId,
- updated: '1234' as Timestamp,
- robot_id: 'robot_id' as RobotId,
- robot_run_id: 'robot_run_id' as RobotRunId,
- properties: {},
- fix_suggestions: [],
- };
- assert.equal(element._hideActions(showActions, robotComment), true);
- });
-
- test('setting project name loads the project config', async () => {
- const projectName = 'foo/bar/baz' as RepoName;
- const getProjectStub = stubRestApi('getProjectConfig').returns(
- Promise.resolve({} as ConfigInfo)
- );
- element.projectName = projectName;
- await flush();
- assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
- });
-
- test('optionally show file path', () => {
- // Path info doesn't exist when showFilePath is false. Because it's in a
- // dom-if it is not yet in the dom.
- assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
-
- const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
- element.changeNum = 123 as NumericChangeId;
- element.projectName = 'test project' as RepoName;
- element.path = 'path/to/file';
- element.patchNum = 3 as PatchSetNum;
- element.lineNum = 5;
- element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
- element.showFilePath = true;
- flush();
- assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
- assert.notEqual(
- getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
- .display,
- 'none'
- );
- assert.isTrue(
- commentStub.calledWithExactly(
- element.changeNum,
- element.projectName,
- 'comment_id' as UrlEncodedCommentId
- )
- );
- });
-
- test('_computeDisplayPath', () => {
- let path = 'path/to/file';
- assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
- element.lineNum = 5;
- assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
- element.patchNum = 3 as PatchSetNum;
- path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
- assert.equal(element._computeDisplayPath(path), 'Patchset');
- });
-
- test('_computeDisplayLine', () => {
- element.lineNum = 5;
- assert.equal(
- element._computeDisplayLine(element.lineNum, element.range),
- '#5'
- );
-
- element.path = SpecialFilePath.COMMIT_MESSAGE;
- element.lineNum = 5;
- assert.equal(
- element._computeDisplayLine(element.lineNum, element.range),
- '#5'
- );
-
- element.lineNum = undefined;
- element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
- assert.equal(
- element._computeDisplayLine(element.lineNum, element.range),
- ''
- );
- });
- });
-});
-
-suite('comment action tests with unresolved thread', () => {
- let element: GrCommentThread;
- let addDraftServiceStub: SinonStub;
- let saveDiffDraftStub: SinonStub;
- let comment = {
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Done',
- };
- const peanutButterComment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- message: 'is this a crossover episode!?',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- path: '/path/to/file.txt',
- unresolved: true,
- patch_set: 3 as PatchSetNum,
- };
- const mockResponse: Response = {
- ...new Response(),
- headers: {} as Headers,
- redirected: false,
- status: 200,
- statusText: '',
- type: '' as ResponseType,
- url: '',
- ok: true,
- text() {
- return Promise.resolve(")]}'\n" + JSON.stringify(comment));
- },
- };
- let saveDiffDraftPromiseResolver: (value?: Response) => void;
- setup(() => {
- addDraftServiceStub = stubComments('addDraft');
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- saveDiffDraftStub = stubRestApi('saveDiffDraft').returns(
- new Promise<Response>(
- resolve =>
- (saveDiffDraftPromiseResolver = resolve as (value?: Response) => void)
- )
- );
- stubRestApi('deleteDiffDraft').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- element = withCommentFixture.instantiate();
- element.patchNum = 1 as PatchSetNum;
- element.changeNum = 1 as NumericChangeId;
- element.comments = [peanutButterComment];
- flush();
});
- test('reply', () => {
- saveDiffDraftPromiseResolver(mockResponse);
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- const reportStub = stubReporting('recordDraftInteraction');
- assert.ok(commentEl);
-
- const replyBtn = element.$.replyBtn;
- tap(replyBtn);
- flush();
- const draft = addDraftServiceStub.firstCall.args[0];
- assert.isOk(draft);
- assert.notOk(draft.message, 'message should be empty');
- assert.equal(
- draft.in_reply_to,
- 'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
- );
- assert.isTrue(reportStub.calledOnce);
+ test('renders with actions unresolved', async () => {
+ element.thread = createThread(c1, {...c2, unresolved: true});
+ await element.updateComplete;
+ expect(queryAndAssert(element, '#container')).dom.to.equal(`
+ <div id="container">
+ <h3 class="assistive-tech-only">Unresolved Comment thread by Kermit</h3>
+ <div class="comment-box unresolved" tabindex="0">
+ <gr-comment show-patchset=""></gr-comment>
+ <gr-comment show-patchset=""></gr-comment>
+ <div id="actionsContainer">
+ <span id="unresolvedLabel">
+ Unresolved
+ </span>
+ <div id="actions">
+ <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
+ </iron-icon>
+ <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
+ Reply
+ </gr-button>
+ <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
+ Quote
+ </gr-button>
+ <gr-button aria-disabled="false" class="action ack" id="ackBtn" link="" role="button" tabindex="0">
+ Ack
+ </gr-button>
+ <gr-button aria-disabled="false" class="action done" id="doneBtn" link="" role="button" tabindex="0">
+ Done
+ </gr-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ `);
});
- test('quote reply', () => {
- saveDiffDraftPromiseResolver(mockResponse);
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- const reportStub = stubReporting('recordDraftInteraction');
- assert.ok(commentEl);
-
- const quoteBtn = element.$.quoteBtn;
- tap(quoteBtn);
- flush();
-
- const draft = addDraftServiceStub.firstCall.args[0];
- // the quote reply is not autmatically saved so verify that id is not set
- assert.isNotOk(draft.id);
- // verify that the draft returned was not saved
- assert.isNotOk(saveDiffDraftStub.called);
- assert.equal(draft.message, '> is this a crossover episode!?\n\n');
- assert.equal(
- draft.in_reply_to,
- 'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
- );
- assert.isTrue(reportStub.calledOnce);
+ test('renders with diff', async () => {
+ element.showCommentContext = true;
+ element.thread = createThread(commentWithContext);
+ await element.updateComplete;
+ expect(queryAndAssert(element, '.diff-container')).dom.to.equal(`
+ <div class="diff-container">
+ <gr-diff
+ class="disable-context-control-buttons hide-line-length-indicator no-left"
+ id="diff"
+ style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
+ >
+ </gr-diff>
+ <div class="view-diff-container">
+ <a href="">
+ <gr-button aria-disabled="false" class="view-diff-button" link="" role="button" tabindex="0">
+ View Diff
+ </gr-button>
+ </a>
+ </div>
+ </div>
+ `);
});
- test('quote reply multiline', () => {
- saveDiffDraftPromiseResolver(mockResponse);
- const reportStub = stubReporting('recordDraftInteraction');
- element.comments = [
+ suite('action button clicks', () => {
+ let savePromise: MockPromise<void>;
+ let stub: SinonStub;
+
+ setup(async () => {
+ savePromise = mockPromise<void>();
+ stub = stubComments('saveDraft').returns(savePromise);
+
+ element.thread = createThread(c1, {...c2, unresolved: true});
+ await element.updateComplete;
+ });
+
+ test('handle Ack', async () => {
+ tap(queryAndAssert(element, '#ackBtn'));
+ waitUntilCalled(stub, 'saveDraft()');
+ assert.equal(stub.lastCall.firstArg.message, 'Ack');
+ assert.equal(stub.lastCall.firstArg.unresolved, false);
+ assert.isTrue(element.saving);
+
+ savePromise.resolve();
+ await element.updateComplete;
+ assert.isFalse(element.saving);
+ });
+
+ test('handle Done', async () => {
+ tap(queryAndAssert(element, '#doneBtn'));
+ waitUntilCalled(stub, 'saveDraft()');
+ assert.equal(stub.lastCall.firstArg.message, 'Done');
+ assert.equal(stub.lastCall.firstArg.unresolved, false);
+ });
+
+ test('handle Reply', async () => {
+ assert.isUndefined(element.unsavedComment);
+ tap(queryAndAssert(element, '#replyBtn'));
+ assert.equal(element.unsavedComment?.message, '');
+ });
+
+ test('handle Quote', async () => {
+ assert.isUndefined(element.unsavedComment);
+ tap(queryAndAssert(element, '#quoteBtn'));
+ assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
+ });
+ });
+
+ suite('self removal when empty thread changed to editing:false', () => {
+ let threadEl: GrCommentThread;
+
+ setup(async () => {
+ threadEl = basicFixture.instantiate();
+ threadEl.thread = createThread();
+ });
+
+ test('new thread el normally has a parent and an unsaved comment', async () => {
+ await waitUntil(() => threadEl.editing);
+ assert.isOk(threadEl.unsavedComment);
+ assert.isOk(threadEl.parentElement);
+ });
+
+ test('thread el removed after clicking CANCEL', async () => {
+ await waitUntil(() => threadEl.editing);
+
+ const commentEl = queryAndAssert(threadEl, 'gr-comment');
+ const buttonEl = queryAndAssert(commentEl, 'gr-button.cancel');
+ tap(buttonEl);
+
+ await waitUntil(() => !threadEl.editing);
+ assert.isNotOk(threadEl.parentElement);
+ });
+ });
+
+ test('comments are sorted correctly', () => {
+ const comments: CommentInfo[] = [
{
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- path: 'test',
- line: 5,
- message: 'is this a crossover episode!?\nIt might be!',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+ id: 'jacks_confession' as UrlEncodedCommentId,
+ in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, too',
+ updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, jack',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i’m running away',
+ updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sallys_defiance' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i will poison you so i can get away',
+ updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'no i will pull a thread and your arm will fall off',
+ updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sallys_mission' as UrlEncodedCommentId,
+ message: 'i have to find santa',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
},
];
- flush();
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const quoteBtn = element.$.quoteBtn;
- tap(quoteBtn);
- flush();
-
- const draft = addDraftServiceStub.firstCall.args[0];
- assert.equal(
- draft.message,
- '> is this a crossover episode!?\n> It might be!\n\n'
- );
- assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('ack', async () => {
- saveDiffDraftPromiseResolver(mockResponse);
- comment = {
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Ack',
- };
- const reportStub = stubReporting('recordDraftInteraction');
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
- assert.isOk(ackBtn);
- tap(ackBtn!);
- await flush();
- const draft = addDraftServiceStub.firstCall.args[0];
- assert.equal(draft.message, 'Ack');
- assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
- assert.isNotOk(draft.unresolved);
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('done', async () => {
- saveDiffDraftPromiseResolver(mockResponse);
- comment = {
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Done',
- };
- const reportStub = stubReporting('recordDraftInteraction');
- assert.isFalse(saveDiffDraftStub.called);
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
- assert.isOk(doneBtn);
- tap(doneBtn!);
- await flush();
- const draft = addDraftServiceStub.firstCall.args[0];
- // Since the reply is automatically saved, verify that draft.id is set in
- // the model
- assert.equal(draft.id, '7afa4931_de3d65bd');
- assert.equal(draft.message, 'Done');
- assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
- assert.isNotOk(draft.unresolved);
- assert.isTrue(reportStub.calledOnce);
- assert.isTrue(saveDiffDraftStub.called);
- });
-
- test('save', async () => {
- saveDiffDraftPromiseResolver(mockResponse);
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.path = '/path/to/file.txt';
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- element.shadowRoot?.querySelector('gr-comment')?._fireSave();
-
- await flush();
- assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
- });
-
- test('please fix', async () => {
- comment = peanutButterComment;
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
- const promise = mockPromise();
- commentEl!.addEventListener('create-fix-comment', async () => {
- assert.isTrue(saveDiffDraftStub.called);
- assert.isFalse(addDraftServiceStub.called);
- saveDiffDraftPromiseResolver(mockResponse);
- // flushing so the saveDiffDraftStub resolves and the draft is returned
- await flush();
- assert.isTrue(saveDiffDraftStub.called);
- assert.isTrue(addDraftServiceStub.called);
- const draft = saveDiffDraftStub.firstCall.args[2];
- assert.equal(
- draft.message,
- '> is this a crossover episode!?\n\nPlease fix.'
- );
- assert.equal(
- draft.in_reply_to,
- 'baf0414d_60047215' as UrlEncodedCommentId
- );
- assert.isTrue(draft.unresolved);
- promise.resolve();
- });
- assert.isFalse(saveDiffDraftStub.called);
- assert.isFalse(addDraftServiceStub.called);
- commentEl!.dispatchEvent(
- new CustomEvent('create-fix-comment', {
- detail: {comment: commentEl!.comment},
- composed: true,
- bubbles: false,
- })
- );
- await promise;
- });
-
- test('discard', async () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.path = '/path/to/file.txt';
- assert.isOk(element.comments[0]);
- const deleteDraftStub = stubComments('deleteDraft');
- element.push(
- 'comments',
- element._newReply(
- element.comments[0]!.id as UrlEncodedCommentId,
- 'it’s pronouced jiff, not giff'
- )
- );
- await flush();
-
- const draftEl = element.root?.querySelectorAll('gr-comment')[1];
- assert.ok(draftEl);
- draftEl?._fireSave(); // tell the model about the draft
- const promise = mockPromise();
- draftEl!.addEventListener('comment-discard', () => {
- assert.isTrue(deleteDraftStub.called);
- promise.resolve();
- });
- draftEl!._fireDiscard();
- await promise;
- });
-
- test('discard with a single comment still fires event with previous rootId', async () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.path = '/path/to/file.txt';
- element.comments = [];
- element.addOrEditDraft(1 as LineNumber);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [draft];
- flush();
- const rootId = element.rootId;
- assert.isOk(rootId);
- flush();
- const draftEl = element.root?.querySelectorAll('gr-comment')[0];
- assert.ok(draftEl);
- const deleteDraftStub = stubComments('deleteDraft');
- const promise = mockPromise();
- draftEl!.addEventListener('comment-discard', () => {
- assert.isTrue(deleteDraftStub.called);
- promise.resolve();
- });
- draftEl!._fireDiscard();
- await promise;
- assert.isTrue(deleteDraftStub.called);
- });
-
- test('comment-update', () => {
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- const updatedComment = {
- id: element.comments[0].id,
- foo: 'bar',
- };
- assert.isOk(commentEl);
- commentEl!.dispatchEvent(
- new CustomEvent('comment-update', {
- detail: {comment: updatedComment},
- composed: true,
- bubbles: true,
- })
- );
- assert.strictEqual(element.comments[0], updatedComment);
- });
-
- suite('jack and sally comment data test consolidation', () => {
- setup(() => {
- element.comments = [
- {
- id: 'jacks_reply' as UrlEncodedCommentId,
- message: 'i like you, too',
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- updated: '2015-12-25 15:00:20.396000000' as Timestamp,
- path: 'abcd',
- unresolved: false,
- },
- {
- id: 'sallys_confession' as UrlEncodedCommentId,
- in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
- message: 'i like you, jack',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- path: 'abcd',
- },
- {
- id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
- message: 'i’m running away',
- updated: '2015-10-31 09:00:20.396000000' as Timestamp,
- path: 'abcd',
- },
- {
- id: 'sallys_defiance' as UrlEncodedCommentId,
- message: 'i will poison you so i can get away',
- updated: '2015-10-31 15:00:20.396000000' as Timestamp,
- path: 'abcd',
- },
- ];
- });
-
- test('orphan replies', () => {
- assert.equal(4, element._orderedComments.length);
- });
-
- test('keyboard shortcuts', () => {
- const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
- pressAndReleaseKeyOn(element, 69, null, 'e');
- assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
- pressAndReleaseKeyOn(element, 69, 'shift', 'E');
- assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
- });
-
- test('comment in_reply_to is either null or most recent comment', () => {
- element._createReplyComment('dummy', true);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [...element.comments, draft];
- flush();
- assert.equal(element._orderedComments.length, 5);
- assert.equal(
- element._orderedComments[4].in_reply_to,
- 'jacks_reply' as UrlEncodedCommentId
- );
- });
-
- test('resolvable comments', () => {
- assert.isFalse(element.unresolved);
- element._createReplyComment('dummy', true, true);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [...element.comments, draft];
- flush();
- assert.isTrue(element.unresolved);
- });
-
- test('_setInitialExpandedState with unresolved', () => {
- element.unresolved = true;
- element._setInitialExpandedState();
- for (let i = 0; i < element.comments.length; i++) {
- assert.isFalse(element.comments[i].collapsed);
- }
- });
-
- test('_setInitialExpandedState without unresolved', () => {
- element.unresolved = false;
- element._setInitialExpandedState();
- for (let i = 0; i < element.comments.length; i++) {
- assert.isTrue(element.comments[i].collapsed);
- }
- });
-
- test('_setInitialExpandedState with robot_ids', () => {
- for (let i = 0; i < element.comments.length; i++) {
- (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
- }
- element._setInitialExpandedState();
- for (let i = 0; i < element.comments.length; i++) {
- assert.isFalse(element.comments[i].collapsed);
- }
- });
-
- test('_setInitialExpandedState with collapsed state', () => {
- element.comments[0].collapsed = false;
- element.unresolved = false;
- element._setInitialExpandedState();
- assert.isFalse(element.comments[0].collapsed);
- for (let i = 1; i < element.comments.length; i++) {
- assert.isTrue(element.comments[i].collapsed);
- }
- });
- });
-
- test('_computeHostClass', () => {
- assert.equal(element._computeHostClass(true), 'unresolved');
- assert.equal(element._computeHostClass(false), '');
- });
-
- test('addDraft sets unresolved state correctly', () => {
- let unresolved = true;
- let draft;
- element.comments = [];
- element.path = 'abcd';
- element.addDraft(undefined, undefined, unresolved);
- draft = addDraftServiceStub.lastCall.args[0];
- assert.equal(draft.unresolved, true);
-
- unresolved = false; // comment should get added as actually resolved.
- element.comments = [];
- element.addDraft(undefined, undefined, unresolved);
- draft = addDraftServiceStub.lastCall.args[0];
- assert.equal(draft.unresolved, false);
-
- element.comments = [];
- element.addDraft();
- draft = addDraftServiceStub.lastCall.args[0];
- assert.equal(draft.unresolved, true);
- });
-
- test('_newDraft with root', () => {
- const draft = element._newDraft();
- assert.equal(draft.patch_set, 3 as PatchSetNum);
- });
-
- test('_newDraft with no root', () => {
- element.comments = [];
- element.diffSide = Side.RIGHT;
- element.patchNum = 2 as PatchSetNum;
- const draft = element._newDraft();
- assert.equal(draft.patch_set, 2 as PatchSetNum);
- });
-
- test('new comment gets created', () => {
- element.comments = [];
- element.path = 'abcd';
- element.addOrEditDraft(1);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [draft];
- flush();
- assert.equal(element.comments.length, 1);
- // Mock a submitted comment.
- element.comments[0].id = (element.comments[0] as UIDraft)
- .__draftID as UrlEncodedCommentId;
- delete (element.comments[0] as UIDraft).__draft;
- element.addOrEditDraft(1);
- assert.equal(addDraftServiceStub.callCount, 2);
- });
-
- test('unresolved label', () => {
- element.unresolved = false;
- const label = element.shadowRoot?.querySelector('#unresolvedLabel');
- assert.isOk(label);
- assert.isFalse(label!.hasAttribute('hidden'));
- element.unresolved = true;
- assert.isFalse(label!.hasAttribute('hidden'));
- });
-
- test('draft comments are at the end of orderedComments', () => {
- element.comments = [
+ const results = sortComments(comments);
+ assert.deepEqual(results, [
{
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: '2' as UrlEncodedCommentId,
- line: 5,
- message: 'Earlier draft',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- __draft: true,
+ id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i’m running away',
+ updated: '2015-10-31 09:00:20.396000000' as Timestamp,
},
{
- author: {
- name: 'Mr. Peanutbutter2',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: '1' as UrlEncodedCommentId,
- line: 5,
- message: 'This comment was left last but is not a draft',
- updated: '2015-12-10 19:48:33.843000000' as Timestamp,
+ id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'no i will pull a thread and your arm will fall off',
+ updated: '2015-10-31 11:00:20.396000000' as Timestamp,
},
{
- author: {
- name: 'Mr. Peanutbutter2',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: '3' as UrlEncodedCommentId,
- line: 5,
- message: 'Later draft',
- updated: '2015-12-09 19:48:33.843000000' as Timestamp,
- __draft: true,
+ id: 'sallys_defiance' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i will poison you so i can get away',
+ updated: '2015-10-31 15:00:20.396000000' as Timestamp,
},
- ];
- assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
- assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
- assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
- });
-
- test('reflects lineNum and commentSide to attributes', () => {
- element.lineNum = 7;
- element.diffSide = Side.LEFT;
-
- assert.equal(element.getAttribute('line-num'), '7');
- assert.equal(element.getAttribute('diff-side'), Side.LEFT);
- });
-
- test('reflects range to JSON serialized attribute if set', () => {
- element.range = {
- start_line: 4,
- end_line: 5,
- start_character: 6,
- end_character: 7,
- };
-
- assert.isOk(element.getAttribute('range'));
- assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
- start_line: 4,
- end_line: 5,
- start_character: 6,
- end_character: 7,
- });
- });
-
- test('removes range attribute if range is unset', () => {
- element.range = {
- start_line: 4,
- end_line: 5,
- start_character: 6,
- end_character: 7,
- };
- element.range = undefined;
-
- assert.notOk(element.hasAttribute('range'));
- });
-});
-
-suite('comment action tests on resolved comments', () => {
- let element: GrCommentThread;
-
- setup(() => {
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- stubRestApi('saveDiffDraft').returns(
- Promise.resolve({
- ...new Response(),
- ok: true,
- text() {
- return Promise.resolve(
- ")]}'\n" +
- JSON.stringify({
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Done',
- })
- );
- },
- })
- );
- stubRestApi('deleteDiffDraft').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- element = withCommentFixture.instantiate();
- element.patchNum = 1 as PatchSetNum;
- element.changeNum = 1 as NumericChangeId;
- element.comments = [
{
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- line: 5,
- message: 'is this a crossover episode!?',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- path: '/path/to/file.txt',
- unresolved: false,
+ id: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, jack',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
},
- ];
- flush();
- });
-
- test('ack and done should be hidden', () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
- const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
- assert.equal(ackBtn, null);
- assert.equal(doneBtn, null);
- });
-
- test('reply and quote button should be visible', () => {
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
- const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
- assert.ok(replyBtn);
- assert.ok(quoteBtn);
+ {
+ id: 'sallys_mission' as UrlEncodedCommentId,
+ message: 'i have to find santa',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'jacks_confession' as UrlEncodedCommentId,
+ in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, too',
+ updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+ },
+ ]);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index d1ddd31..1411c88 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -27,53 +27,50 @@
import '../gr-tooltip-content/gr-tooltip-content';
import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
import '../gr-account-label/gr-account-label';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment_html';
-import {getRootElement} from '../../../scripts/rootElement';
import {getAppContext} from '../../../services/app-context';
-import {customElement, observe, property} from '@polymer/decorators';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {GrTextarea} from '../gr-textarea/gr-textarea';
import {GrOverlay} from '../gr-overlay/gr-overlay';
import {
AccountDetailInfo,
- BasePatchSetNum,
- ConfigInfo,
+ CommentLinks,
NumericChangeId,
- PatchSetNum,
RepoName,
+ RobotCommentInfo,
} from '../../../types/common';
-import {GrButton} from '../gr-button/gr-button';
import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
import {
- isDraft,
+ Comment,
+ isDraftOrUnsaved,
isRobot,
- UIComment,
- UIDraft,
- UIRobot,
+ isUnsaved,
} from '../../../utils/comment-util';
-import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
-import {pluralize} from '../../../utils/string-util';
+import {
+ OpenFixPreviewEventDetail,
+ ValueChangedEvent,
+} from '../../../types/events';
+import {fire, fireEvent} from '../../../utils/event-util';
import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
-import {Interaction} from '../../../constants/reporting';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {classMap} from 'lit/directives/class-map';
+import {LineNumber} from '../../../api/diff';
+import {CommentSide} from '../../../constants/constants';
+import {getRandomInt} from '../../../utils/math-util';
+import {Subject} from 'rxjs';
+import {debounceTime} from 'rxjs/operators';
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVED_MESSAGE = 'All changes saved';
const UNSAVED_MESSAGE = 'Unable to save draft';
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
const FILE = 'FILE';
+// visible for testing
+export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
+
export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
/**
@@ -88,25 +85,21 @@
'When disagreeing, explain the advantage of your approach.',
];
-interface CommentOverlays {
- confirmDelete?: GrOverlay | null;
- confirmDiscard?: GrOverlay | null;
+declare global {
+ interface HTMLElementEventMap {
+ 'comment-editing-changed': CustomEvent<boolean>;
+ 'comment-unresolved-changed': CustomEvent<boolean>;
+ 'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
+ }
}
-export interface GrComment {
- $: {
- container: HTMLDivElement;
- resolvedCheckbox: HTMLInputElement;
- header: HTMLDivElement;
- };
+export interface CommentAnchorTapEventDetail {
+ number: LineNumber;
+ side?: CommentSide;
}
@customElement('gr-comment')
-export class GrComment extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
-
+export class GrComment extends LitElement {
/**
* Fired when the create fix comment action is triggered.
*
@@ -120,30 +113,6 @@
*/
/**
- * Fired when this comment is discarded.
- *
- * @event comment-discard
- */
-
- /**
- * Fired when this comment is edited.
- *
- * @event comment-edit
- */
-
- /**
- * Fired when this comment is saved.
- *
- * @event comment-save
- */
-
- /**
- * Fired when this comment is updated.
- *
- * @event comment-update
- */
-
- /**
* Fired when editing status changed.
*
* @event comment-editing-changed
@@ -155,124 +124,102 @@
* @event comment-anchor-tap
*/
- @property({type: Number})
- changeNum?: NumericChangeId;
+ @query('#editTextarea')
+ textarea?: GrTextarea;
- @property({type: String})
- projectName?: RepoName;
+ @query('#container')
+ container?: HTMLElement;
- @property({type: Object, notify: true, observer: '_commentChanged'})
- comment?: UIComment;
+ @query('#resolvedCheckbox')
+ resolvedCheckbox?: HTMLInputElement;
+ @query('#confirmDeleteOverlay')
+ confirmDeleteOverlay?: GrOverlay;
+
+ @property({type: Object})
+ comment?: Comment;
+
+ // TODO: Move this out of gr-comment. gr-comment should not have a comments
+ // property. This is only used for hasHumanReply at the moment.
@property({type: Array})
- comments?: UIComment[];
-
- @property({type: Boolean, reflectToAttribute: true})
- isRobotComment = false;
-
- @property({type: Boolean, reflectToAttribute: true})
- disabled = false;
-
- @property({type: Boolean, observer: '_draftChanged'})
- draft = false;
-
- @property({type: Boolean, observer: '_editingChanged'})
- editing = false;
-
- // Assigns a css property to the comment hiding the comment while it's being
- // discarded
- @property({
- type: Boolean,
- reflectToAttribute: true,
- })
- discarding = false;
-
- @property({type: Boolean})
- hasChildren?: boolean;
-
- @property({type: String})
- patchNum?: PatchSetNum;
-
- @property({type: Boolean})
- showActions?: boolean;
-
- @property({type: Boolean})
- _showHumanActions?: boolean;
-
- @property({type: Boolean})
- _showRobotActions?: boolean;
-
- @property({
- type: Boolean,
- reflectToAttribute: true,
- observer: '_toggleCollapseClass',
- })
- collapsed = true;
-
- @property({type: Object})
- projectConfig?: ConfigInfo;
-
- @property({type: Boolean})
- robotButtonDisabled = false;
-
- @property({type: Boolean})
- _hasHumanReply?: boolean;
-
- @property({type: Boolean})
- _isAdmin = false;
-
- @property({type: Object})
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- _xhrPromise?: Promise<any>; // Used for testing.
-
- @property({type: String, observer: '_messageTextChanged'})
- _messageText = '';
-
- @property({type: String})
- side?: string;
-
- @property({type: Boolean})
- resolved = false;
-
- // Intentional to share the object across instances.
- @property({type: Object})
- _numPendingDraftRequests: {number: number} = {number: 0};
-
- @property({type: Boolean})
- _enableOverlay = false;
+ comments?: Comment[];
/**
- * Property for storing references to overlay elements. When the overlays
- * are moved to getRootElement() to be shown they are no-longer
- * children, so they can't be queried along the tree, so they are stored
- * here.
+ * Initial collapsed state of the comment.
*/
- @property({type: Object})
- _overlays: CommentOverlays = {};
+ @property({type: Boolean, attribute: 'initially-collapsed'})
+ initiallyCollapsed?: boolean;
+
+ /**
+ * This is the *current* (internal) collapsed state of the comment. Do not set
+ * from the outside. Use `initiallyCollapsed` instead. This is just a
+ * reflected property such that css rules can be based on it.
+ */
+ @property({type: Boolean, reflect: true})
+ collapsed?: boolean;
+
+ @property({type: Boolean, attribute: 'robot-button-disabled'})
+ robotButtonDisabled = false;
+
+ /* internal only, but used in css rules */
+ @property({type: Boolean, reflect: true})
+ saving = false;
+
+ /**
+ * `saving` and `autoSaving` are separate and cannot be set at the same time.
+ * `saving` affects the UI state (disabled buttons, etc.) and eventually
+ * leaves editing mode, but `autoSaving` just happens in the background
+ * without the user noticing.
+ */
+ @state()
+ autoSaving?: Promise<void>;
+
+ @state()
+ changeNum?: NumericChangeId;
+
+ @state()
+ editing = false;
+
+ @state()
+ commentLinks: CommentLinks = {};
+
+ @state()
+ repoName?: RepoName;
+
+ /* The 'dirty' state of the comment.message, which will be saved on demand. */
+ @state()
+ messageText = '';
+
+ /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
+ @state()
+ unresolved = true;
@property({type: Boolean})
- _showRespectfulTip = false;
+ showConfirmDeleteOverlay = false;
@property({type: Boolean})
- showPatchset = true;
+ showRespectfulTip = false;
@property({type: String})
- _respectfulReviewTip?: string;
+ respectfulReviewTip?: string;
@property({type: Boolean})
- _respectfulTipDismissed = false;
+ respectfulTipDismissed = false;
@property({type: Boolean})
- _unableToSave = false;
+ unableToSave = false;
- @property({type: Object})
- _selfAccount?: AccountDetailInfo;
+ @property({type: Boolean, attribute: 'show-patchset'})
+ showPatchset = false;
- @property({type: Boolean})
+ @property({type: Boolean, attribute: 'show-ported-comment'})
showPortedComment = false;
- /** Called in disconnectedCallback. */
- private cleanups: (() => void)[] = [];
+ @state()
+ account?: AccountDetailInfo;
+
+ @state()
+ isAdmin = false;
private readonly restApiService = getAppContext().restApiService;
@@ -280,67 +227,700 @@
private readonly reporting = getAppContext().reportingService;
- private readonly commentsService = getAppContext().commentsService;
+ private readonly changeModel = getAppContext().changeModel;
- private fireUpdateTask?: DelayedTask;
+ private readonly commentsModel = getAppContext().commentsModel;
- private storeTask?: DelayedTask;
+ private readonly userModel = getAppContext().userModel;
- private draftToastTask?: DelayedTask;
+ private readonly configModel = getAppContext().configModel;
- override connectedCallback() {
- super.connectedCallback();
- this.restApiService.getAccount().then(account => {
- this._selfAccount = account;
- });
- if (this.editing) {
- this.collapsed = false;
- } else if (this.comment) {
- this.collapsed = !!this.comment.collapsed;
- }
- this._getIsAdmin().then(isAdmin => {
- this._isAdmin = !!isAdmin;
- });
- this.cleanups.push(
- addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+ private readonly shortcuts = new ShortcutController(this);
+
+ /**
+ * This is triggered when the user types into the editing textarea. We then
+ * debounce it and call autoSave().
+ */
+ private autoSaveTrigger$ = new Subject();
+
+ /**
+ * Set to the content of DraftInfo when entering editing mode.
+ * Only used for "Cancel".
+ */
+ private originalMessage = '';
+
+ /**
+ * Set to the content of DraftInfo when entering editing mode.
+ * Only used for "Cancel".
+ */
+ private originalUnresolved = false;
+
+ constructor() {
+ super();
+ subscribe(this, this.userModel.account$, x => (this.account = x));
+ subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
+ subscribe(
+ this,
+ this.configModel.repoCommentLinks$,
+ x => (this.commentLinks = x)
);
+ subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+ subscribe(
+ this,
+ this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+ () => {
+ this.autoSave();
+ }
+ );
+ this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc());
for (const key of ['s', Key.ENTER]) {
for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
- addShortcut(this, {key, modifiers: [modifier]}, e =>
- this._handleSaveKey(e)
- );
+ this.shortcuts.addLocal({key, modifiers: [modifier]}, () => {
+ this.save();
+ });
}
}
}
override disconnectedCallback() {
- for (const cleanup of this.cleanups) cleanup();
- this.cleanups = [];
- this.fireUpdateTask?.cancel();
- this.storeTask?.cancel();
- this.draftToastTask?.cancel();
- if (this.textarea) {
- this.textarea.closeDropdown();
- }
+ // Clean up emoji dropdown.
+ if (this.textarea) this.textarea.closeDropdown();
super.disconnectedCallback();
}
- /** 2nd argument is for *triggering* the computation only. */
- _getAuthor(comment?: UIComment, _?: unknown) {
- return comment?.author || this._selfAccount;
+ static override get styles() {
+ return [
+ sharedStyles,
+ css`
+ :host {
+ display: block;
+ font-family: var(--font-family);
+ padding: var(--spacing-m);
+ }
+ :host([collapsed]) {
+ padding: var(--spacing-s) var(--spacing-m);
+ }
+ :host([saving]) {
+ pointer-events: none;
+ }
+ :host([saving]) .actions,
+ :host([saving]) .robotActions,
+ :host([saving]) .date {
+ opacity: 0.5;
+ }
+ .body {
+ padding-top: var(--spacing-m);
+ }
+ .header {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ }
+ .headerLeft > span {
+ font-weight: var(--font-weight-bold);
+ }
+ .headerMiddle {
+ color: var(--deemphasized-text-color);
+ flex: 1;
+ overflow: hidden;
+ }
+ .draftLabel,
+ .draftTooltip {
+ color: var(--deemphasized-text-color);
+ display: inline;
+ }
+ .date {
+ justify-content: flex-end;
+ text-align: right;
+ white-space: nowrap;
+ }
+ span.date {
+ color: var(--deemphasized-text-color);
+ }
+ span.date:hover {
+ text-decoration: underline;
+ }
+ .actions,
+ .robotActions {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 0;
+ }
+ .robotActions {
+ /* Better than the negative margin would be to remove the gr-button
+ * padding, but then we would also need to fix the buttons that are
+ * inserted by plugins. :-/ */
+ margin: 4px 0 -4px;
+ }
+ .action {
+ margin-left: var(--spacing-l);
+ }
+ .rightActions {
+ display: flex;
+ justify-content: flex-end;
+ }
+ .rightActions gr-button {
+ --gr-button-padding: 0 var(--spacing-s);
+ }
+ .editMessage {
+ display: block;
+ margin: var(--spacing-m) 0;
+ width: 100%;
+ }
+ .show-hide {
+ margin-left: var(--spacing-s);
+ }
+ .robotId {
+ color: var(--deemphasized-text-color);
+ margin-bottom: var(--spacing-m);
+ }
+ .robotRun {
+ margin-left: var(--spacing-m);
+ }
+ .robotRunLink {
+ margin-left: var(--spacing-m);
+ }
+ /* just for a11y */
+ input.show-hide {
+ display: none;
+ }
+ label.show-hide {
+ cursor: pointer;
+ display: block;
+ }
+ label.show-hide iron-icon {
+ vertical-align: top;
+ }
+ :host([collapsed]) #container .body {
+ padding-top: 0;
+ }
+ #container .collapsedContent {
+ display: block;
+ overflow: hidden;
+ padding-left: var(--spacing-m);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .resolve,
+ .unresolved {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ margin: 0;
+ }
+ .resolve label {
+ color: var(--comment-text-color);
+ }
+ gr-dialog .main {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ }
+ #deleteBtn {
+ --gr-button-text-color: var(--deemphasized-text-color);
+ --gr-button-padding: 0;
+ }
+
+ /** Disable select for the caret and actions */
+ .actions,
+ .show-hide {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ .respectfulReviewTip {
+ justify-content: space-between;
+ display: flex;
+ padding: var(--spacing-m);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ margin-bottom: var(--spacing-m);
+ }
+ .respectfulReviewTip div {
+ display: flex;
+ }
+ .respectfulReviewTip div iron-icon {
+ margin-right: var(--spacing-s);
+ }
+ .respectfulReviewTip a {
+ white-space: nowrap;
+ margin-right: var(--spacing-s);
+ padding-left: var(--spacing-m);
+ text-decoration: none;
+ }
+ .pointer {
+ cursor: pointer;
+ }
+ .patchset-text {
+ color: var(--deemphasized-text-color);
+ margin-left: var(--spacing-s);
+ }
+ .headerLeft gr-account-label {
+ --account-max-length: 130px;
+ width: 150px;
+ }
+ .headerLeft gr-account-label::part(gr-account-label-text) {
+ font-weight: var(--font-weight-bold);
+ }
+ .draft gr-account-label {
+ width: unset;
+ }
+ .portedMessage {
+ margin: 0 var(--spacing-m);
+ }
+ .link-icon {
+ cursor: pointer;
+ }
+ `,
+ ];
}
- _getUrlForComment(comment?: UIComment) {
- if (!comment || !this.changeNum || !this.projectName) return '';
+ override render() {
+ if (isUnsaved(this.comment) && !this.editing) return;
+ const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+ return html`
+ <div id="container" class="${classMap(classes)}">
+ <div
+ class="header"
+ id="header"
+ @click="${() => (this.collapsed = !this.collapsed)}"
+ >
+ <div class="headerLeft">
+ ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
+ ${this.renderDraftLabel()}
+ </div>
+ <div class="headerMiddle">${this.renderCollapsedContent()}</div>
+ ${this.renderRunDetails()} ${this.renderDeleteButton()}
+ ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+ </div>
+ <div class="body">
+ ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+ ${this.renderRespectfulTip()} ${this.renderCommentMessage()}
+ ${this.renderHumanActions()} ${this.renderRobotActions()}
+ </div>
+ </div>
+ ${this.renderConfirmDialog()}
+ `;
+ }
+
+ private renderAuthor() {
+ if (isRobot(this.comment)) {
+ const id = this.comment.robot_id;
+ return html`<span class="robotName">${id}</span>`;
+ }
+ const classes = {draft: isDraftOrUnsaved(this.comment)};
+ return html`
+ <gr-account-label
+ .account="${this.comment?.author ?? this.account}"
+ class="${classMap(classes)}"
+ hideStatus
+ >
+ </gr-account-label>
+ `;
+ }
+
+ private renderPortedCommentMessage() {
+ if (!this.showPortedComment) return;
+ if (!this.comment?.patch_set) return;
+ return html`
+ <a href="${this.getUrlForComment()}">
+ <span class="portedMessage" @click="${this.handlePortedMessageClick}">
+ From patchset ${this.comment?.patch_set}]]
+ </span>
+ </a>
+ `;
+ }
+
+ private renderDraftLabel() {
+ if (!isDraftOrUnsaved(this.comment)) return;
+ let label = 'DRAFT';
+ let tooltip =
+ 'This draft is only visible to you. ' +
+ "To publish drafts, click the 'Reply' or 'Start review' button " +
+ "at the top of the change or press the 'a' key.";
+ if (this.unableToSave) {
+ label += ' (Failed to save)';
+ tooltip = 'Unable to save draft. Please try to save again.';
+ }
+ return html`
+ <gr-tooltip-content
+ class="draftTooltip"
+ has-tooltip
+ title="${tooltip}"
+ max-width="20em"
+ show-icon
+ >
+ <span class="draftLabel">${label}</span>
+ </gr-tooltip-content>
+ `;
+ }
+
+ private renderCollapsedContent() {
+ if (!this.collapsed) return;
+ return html`
+ <span class="collapsedContent">${this.comment?.message}</span>
+ `;
+ }
+
+ private renderRunDetails() {
+ if (!isRobot(this.comment)) return;
+ if (!this.comment?.url || this.collapsed) return;
+ return html`
+ <div class="runIdMessage message">
+ <div class="runIdInformation">
+ <a class="robotRunLink" href="${this.comment.url}">
+ <span class="robotRun link">Run Details</span>
+ </a>
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Deleting a comment is an admin feature. It means more than just discarding
+ * a draft. It is an action applied to published comments.
+ */
+ private renderDeleteButton() {
+ if (
+ !this.isAdmin ||
+ isDraftOrUnsaved(this.comment) ||
+ isRobot(this.comment)
+ )
+ return;
+ if (this.collapsed) return;
+ return html`
+ <gr-button
+ id="deleteBtn"
+ title="Delete Comment"
+ link
+ class="action delete"
+ @click="${this.openDeleteCommentOverlay}"
+ >
+ <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+ </gr-button>
+ `;
+ }
+
+ private renderPatchset() {
+ if (!this.showPatchset) return;
+ assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+ return html`
+ <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
+ `;
+ }
+
+ private renderDate() {
+ if (!this.comment?.updated || this.collapsed) return;
+ return html`
+ <span class="separator"></span>
+ <span class="date" tabindex="0" @click="${this.handleAnchorClick}">
+ <gr-date-formatter
+ withTooltip
+ .dateStr="${this.comment.updated}"
+ ></gr-date-formatter>
+ </span>
+ `;
+ }
+
+ private renderToggle() {
+ const icon = this.collapsed
+ ? 'gr-icons:expand-more'
+ : 'gr-icons:expand-less';
+ const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
+ return html`
+ <div class="show-hide" tabindex="0">
+ <label class="show-hide" aria-label="${ariaLabel}">
+ <input
+ type="checkbox"
+ class="show-hide"
+ ?checked="${this.collapsed}"
+ @change="${() => (this.collapsed = !this.collapsed)}"
+ />
+ <iron-icon id="icon" icon="${icon}"></iron-icon>
+ </label>
+ </div>
+ `;
+ }
+
+ private renderRobotAuthor() {
+ if (!isRobot(this.comment) || this.collapsed) return;
+ return html`<div class="robotId">${this.comment.author?.name}</div>`;
+ }
+
+ private renderEditingTextarea() {
+ if (!this.editing || this.collapsed) return;
+ return html`
+ <gr-textarea
+ id="editTextarea"
+ class="editMessage"
+ autocomplete="on"
+ code=""
+ ?disabled="${this.saving}"
+ rows="4"
+ text="${this.messageText}"
+ @text-changed="${(e: ValueChangedEvent) => {
+ // TODO: This is causing a re-render of <gr-comment> on every key
+ // press. Try to avoid always setting `this.messageText` or at least
+ // debounce it. Most of the code can just inspect the current value
+ // of the textare instead of needing a dedicated property.
+ this.messageText = e.detail.value;
+ this.autoSaveTrigger$.next();
+ }}"
+ ></gr-textarea>
+ `;
+ }
+
+ private renderRespectfulTip() {
+ if (!this.showRespectfulTip || this.respectfulTipDismissed) return;
+ if (this.collapsed) return;
+ return html`
+ <div class="respectfulReviewTip">
+ <div>
+ <gr-tooltip-content
+ has-tooltip
+ title="Tips for respectful code reviews."
+ >
+ <iron-icon
+ class="pointer"
+ icon="gr-icons:lightbulb-outline"
+ ></iron-icon>
+ </gr-tooltip-content>
+ ${this.respectfulReviewTip}
+ </div>
+ <div>
+ <a
+ tabindex="-1"
+ @click="${this.onRespectfulReadMoreClick}"
+ href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
+ target="_blank"
+ >
+ Read more
+ </a>
+ <a
+ tabindex="-1"
+ class="close pointer"
+ @click="${this.dismissRespectfulTip}"
+ >
+ Not helpful
+ </a>
+ </div>
+ </div>
+ `;
+ }
+
+ private renderCommentMessage() {
+ if (this.collapsed || this.editing) return;
+ return html`
+ <!--The "message" class is needed to ensure selectability from
+ gr-diff-selection.-->
+ <gr-formatted-text
+ class="message"
+ .content="${this.comment?.message}"
+ .config="${this.commentLinks}"
+ ?noTrailingMargin="${!isDraftOrUnsaved(this.comment)}"
+ ></gr-formatted-text>
+ `;
+ }
+
+ private renderCopyLinkIcon() {
+ // Only show the icon when the thread contains a published comment.
+ if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+ return html`
+ <iron-icon
+ class="copy link-icon"
+ @click="${this.handleCopyLink}"
+ title="Copy link to this comment"
+ icon="gr-icons:link"
+ role="button"
+ tabindex="0"
+ >
+ </iron-icon>
+ `;
+ }
+
+ private renderHumanActions() {
+ if (!this.account || isRobot(this.comment)) return;
+ if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+ return html`
+ <div class="actions">
+ <div class="action resolve">
+ <label>
+ <input
+ type="checkbox"
+ id="resolvedCheckbox"
+ ?checked="${!this.unresolved}"
+ @change="${this.handleToggleResolved}"
+ />
+ Resolved
+ </label>
+ </div>
+ ${this.renderDraftActions()}
+ </div>
+ `;
+ }
+
+ private renderDraftActions() {
+ if (!isDraftOrUnsaved(this.comment)) return;
+ return html`
+ <div class="rightActions">
+ ${this.autoSaving ? html`. ` : ''}
+ ${this.renderCopyLinkIcon()} ${this.renderDiscardButton()}
+ ${this.renderEditButton()} ${this.renderCancelButton()}
+ ${this.renderSaveButton()}
+ </div>
+ `;
+ }
+
+ private renderDiscardButton() {
+ if (this.editing) return;
+ return html`<gr-button
+ link
+ ?disabled="${this.saving}"
+ class="action discard"
+ @click="${this.discard}"
+ >Discard</gr-button
+ >`;
+ }
+
+ private renderEditButton() {
+ if (this.editing) return;
+ return html`<gr-button
+ link
+ ?disabled="${this.saving}"
+ class="action edit"
+ @click="${this.edit}"
+ >Edit</gr-button
+ >`;
+ }
+
+ private renderCancelButton() {
+ if (!this.editing) return;
+ return html`
+ <gr-button
+ link
+ ?disabled="${this.saving}"
+ class="action cancel"
+ @click="${this.cancel}"
+ >Cancel</gr-button
+ >
+ `;
+ }
+
+ private renderSaveButton() {
+ if (!this.editing && !this.unableToSave) return;
+ return html`
+ <gr-button
+ link
+ ?disabled="${this.isSaveDisabled()}"
+ class="action save"
+ @click="${this.save}"
+ >Save</gr-button
+ >
+ `;
+ }
+
+ private renderRobotActions() {
+ if (!this.account || !isRobot(this.comment)) return;
+ const endpoint = html`
+ <gr-endpoint-decorator name="robot-comment-controls">
+ <gr-endpoint-param name="comment" .value="${this.comment}">
+ </gr-endpoint-param>
+ </gr-endpoint-decorator>
+ `;
+ return html`
+ <div class="robotActions">
+ ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
+ ${this.renderPleaseFixButton()}
+ </div>
+ `;
+ }
+
+ private renderShowFixButton() {
+ if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+ return html`
+ <gr-button
+ link
+ secondary
+ class="action show-fix"
+ ?disabled="${this.saving}"
+ @click="${this.handleShowFix}"
+ >
+ Show Fix
+ </gr-button>
+ `;
+ }
+
+ private renderPleaseFixButton() {
+ if (this.hasHumanReply()) return;
+ return html`
+ <gr-button
+ link
+ ?disabled="${this.robotButtonDisabled}"
+ class="action fix"
+ @click="${this.handleFix}"
+ >
+ Please Fix
+ </gr-button>
+ `;
+ }
+
+ private renderConfirmDialog() {
+ if (!this.showConfirmDeleteOverlay) return;
+ return html`
+ <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+ <gr-confirm-delete-comment-dialog
+ id="confirmDeleteComment"
+ @confirm="${this.handleConfirmDeleteComment}"
+ @cancel="${this.closeDeleteCommentOverlay}"
+ >
+ </gr-confirm-delete-comment-dialog>
+ </gr-overlay>
+ `;
+ }
+
+ private getUrlForComment() {
+ const comment = this.comment;
+ if (!comment || !this.changeNum || !this.repoName) return '';
if (!comment.id) throw new Error('comment must have an id');
return GerritNav.getUrlForComment(
this.changeNum as NumericChangeId,
- this.projectName,
+ this.repoName,
comment.id
);
}
- _handlePortedMessageClick() {
+ private firstWillUpdateDone = false;
+
+ firstWillUpdate() {
+ if (this.firstWillUpdateDone) return;
+ this.firstWillUpdateDone = true;
+
+ assertIsDefined(this.comment, 'comment');
+ this.unresolved = this.comment.unresolved ?? true;
+ if (isUnsaved(this.comment)) this.editing = true;
+ if (isDraftOrUnsaved(this.comment)) {
+ this.collapsed = false;
+ } else {
+ this.collapsed = !!this.initiallyCollapsed;
+ }
+ }
+
+ override willUpdate(changed: PropertyValues) {
+ this.firstWillUpdate();
+ if (changed.has('editing')) {
+ this.onEditingChanged();
+ }
+ if (changed.has('unresolved')) {
+ // The <gr-comment-thread> component wants to change its color based on
+ // the (dirty) unresolved state, so let's notify it about changes.
+ fire(this, 'comment-unresolved-changed', this.unresolved);
+ }
+ }
+
+ private handlePortedMessageClick() {
assertIsDefined(this.comment, 'comment');
this.reporting.reportInteraction('navigate-to-original-comment', {
line: this.comment.line,
@@ -348,755 +928,245 @@
});
}
- @observe('editing')
- _onEditingChange(editing?: boolean) {
- this.dispatchEvent(
- new CustomEvent('comment-editing-changed', {
- detail: !!editing,
- bubbles: true,
- composed: true,
- })
+ // private, but visible for testing
+ getRandomInt(from: number, to: number) {
+ return getRandomInt(from, to);
+ }
+
+ private dismissRespectfulTip() {
+ this.respectfulTipDismissed = true;
+ this.reporting.reportInteraction('respectful-tip-dismissed', {
+ tip: this.respectfulReviewTip,
+ });
+ // add a 14-day delay to the tip cache
+ this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+ }
+
+ private onRespectfulReadMoreClick() {
+ this.reporting.reportInteraction('respectful-read-more-clicked');
+ }
+
+ private handleCopyLink() {
+ fireEvent(this, 'copy-comment-link');
+ }
+
+ /** Enter editing mode. */
+ private edit() {
+ if (!isDraftOrUnsaved(this.comment)) {
+ throw new Error('Cannot edit published comment.');
+ }
+ if (this.editing) return;
+ this.editing = true;
+ }
+
+ // TODO: Move this out of gr-comment. gr-comment should not have a comments
+ // property.
+ private hasHumanReply() {
+ if (!this.comment || !this.comments) return false;
+ return this.comments.some(
+ c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
);
- if (!editing) return;
+ }
+
+ // private, but visible for testing
+ getEventPayload(): OpenFixPreviewEventDetail {
+ assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+ return {comment: this.comment, patchNum: this.comment.patch_set};
+ }
+
+ private onEditingChanged() {
+ if (this.editing) {
+ this.collapsed = false;
+ this.messageText = this.comment?.message ?? '';
+ this.unresolved = this.comment?.unresolved ?? true;
+ this.originalMessage = this.messageText;
+ this.originalUnresolved = this.unresolved;
+ setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
+ }
+ this.setRespectfulTip();
+
+ // Parent components such as the reply dialog might be interested in whether
+ // come of their child components are in editing mode.
+ fire(this, 'comment-editing-changed', this.editing);
+ }
+
+ private setRespectfulTip() {
// visibility based on cache this will make sure we only and always show
// a tip once every Math.max(a day, period between creating comments)
const cachedVisibilityOfRespectfulTip =
this.storage.getRespectfulTipVisibility();
- if (!cachedVisibilityOfRespectfulTip) {
- // we still want to show the tip with a probability of 30%
- if (this.getRandomNum(0, 3) >= 1) return;
- this._showRespectfulTip = true;
- const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
- this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
+ if (this.editing && !cachedVisibilityOfRespectfulTip) {
+ // we still want to show the tip with a probability of 33%
+ if (this.getRandomInt(0, 2) >= 1) return;
+ this.showRespectfulTip = true;
+ const randomIdx = this.getRandomInt(0, RESPECTFUL_REVIEW_TIPS.length);
+ this.respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
this.reporting.reportInteraction('respectful-tip-appeared', {
- tip: this._respectfulReviewTip,
+ tip: this.respectfulReviewTip,
});
// update cache
this.storage.setRespectfulTipVisibility();
}
}
- /** Set as a separate method so easy to stub. */
- getRandomNum(min: number, max: number) {
- return Math.floor(Math.random() * (max - min) + min);
+ // private, but visible for testing
+ isSaveDisabled() {
+ assertIsDefined(this.comment, 'comment');
+ if (this.saving) return true;
+ if (this.comment.unresolved !== this.unresolved) return false;
+ return !this.messageText?.trimEnd();
}
- _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
- return showTip && !tipDismissed;
+ private handleEsc() {
+ // vim users don't like ESC to cancel/discard, so only do this when the
+ // comment text is empty.
+ if (!this.messageText?.trimEnd()) this.cancel();
}
- _dismissRespectfulTip() {
- this._respectfulTipDismissed = true;
- this.reporting.reportInteraction('respectful-tip-dismissed', {
- tip: this._respectfulReviewTip,
+ private handleAnchorClick() {
+ assertIsDefined(this.comment, 'comment');
+ fire(this, 'comment-anchor-tap', {
+ number: this.comment.line || FILE,
+ side: this.comment?.side,
});
- // add a 14-day delay to the tip cache
- this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
}
- _onRespectfulReadMoreClick() {
- this.reporting.reportInteraction('respectful-read-more-clicked');
+ private handleFix() {
+ // Handled by <gr-comment-thread>.
+ fire(this, 'create-fix-comment', this.getEventPayload());
}
- get textarea(): GrTextarea | null {
- return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
+ private handleShowFix() {
+ // Handled top-level in the diff and change view components.
+ fire(this, 'open-fix-preview', this.getEventPayload());
}
- get confirmDeleteOverlay() {
- if (!this._overlays.confirmDelete) {
- this._enableOverlay = true;
- flush();
- this._overlays.confirmDelete = this.shadowRoot?.querySelector(
- '#confirmDeleteOverlay'
- ) as GrOverlay | null;
+ // private, but visible for testing
+ cancel() {
+ assertIsDefined(this.comment, 'comment');
+ if (!isDraftOrUnsaved(this.comment)) {
+ throw new Error('only unsaved and draft comments are editable');
}
- return this._overlays.confirmDelete;
+ this.messageText = this.originalMessage;
+ this.unresolved = this.originalUnresolved;
+ this.save();
}
- get confirmDiscardOverlay() {
- if (!this._overlays.confirmDiscard) {
- this._enableOverlay = true;
- flush();
- this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
- '#confirmDiscardOverlay'
- ) as GrOverlay | null;
+ async autoSave() {
+ if (this.saving || this.autoSaving) return;
+ if (!this.editing || !this.comment) return;
+ if (!isDraftOrUnsaved(this.comment)) return;
+ const messageToSave = this.messageText.trimEnd();
+ if (messageToSave === '') return;
+ if (messageToSave === this.comment.message) return;
+
+ try {
+ this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+ await this.autoSaving;
+ } finally {
+ this.autoSaving = undefined;
}
- return this._overlays.confirmDiscard;
}
- _computeShowHideIcon(collapsed: boolean) {
- return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+ async discard() {
+ this.messageText = '';
+ await this.save();
}
- _computeShowHideAriaLabel(collapsed: boolean) {
- return collapsed ? 'Expand' : 'Collapse';
- }
+ async save() {
+ if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
- @observe('showActions', 'isRobotComment')
- _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
- // Polymer 2: check for undefined
- if ([showActions, isRobotComment].includes(undefined)) {
- return;
- }
-
- this._showHumanActions = showActions && !isRobotComment;
- this._showRobotActions = showActions && isRobotComment;
- }
-
- hasPublishedComment(comments?: UIComment[]) {
- if (!comments?.length) return false;
- return comments.length > 1 || !isDraft(comments[0]);
- }
-
- @observe('comment')
- _isRobotComment(comment: UIRobot) {
- this.isRobotComment = !!comment.robot_id;
- }
-
- isOnParent() {
- return this.side === 'PARENT';
- }
-
- _getIsAdmin() {
- return this.restApiService.getIsAdmin();
- }
-
- _computeDraftTooltip(unableToSave: boolean) {
- return unableToSave
- ? 'Unable to save draft. Please try to save again.'
- : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
- "or 'Start review' button at the top of the change or press the 'A' key.";
- }
-
- _computeDraftText(unableToSave: boolean) {
- return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
- }
-
- handleCopyLink() {
- fireEvent(this, 'copy-comment-link');
- }
-
- save(opt_comment?: UIComment) {
- let comment = opt_comment;
- if (!comment) {
- comment = this.comment;
- }
-
- this.set('comment.message', this._messageText);
- this.editing = false;
- this.disabled = true;
-
- if (!this._messageText) {
- return this._discardDraft();
- }
-
- const details = this.commentDetailsForReporting();
- this.reporting.reportInteraction(Interaction.SAVE_COMMENT, details);
- this._xhrPromise = this._saveDraft(comment)
- .then(response => {
- this.disabled = false;
- if (!response.ok) {
- return;
+ try {
+ this.saving = true;
+ this.unableToSave = false;
+ if (this.autoSaving) await this.autoSaving;
+ // Depending on whether `messageToSave` is empty we treat this either as
+ // a discard or a save action.
+ const messageToSave = this.messageText.trimEnd();
+ if (messageToSave === '') {
+ // Don't try to discard UnsavedInfo. Nothing to do then.
+ if (this.comment.id) {
+ await this.commentsModel.discardDraft(this.comment.id);
}
-
- this._eraseDraftCommentFromStorage();
- return this.restApiService.getResponseObject(response).then(obj => {
- const resComment = obj as unknown as UIDraft;
- if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
- resComment.__draft = true;
- // Maintain the ephemeral draft ID for identification by other
- // elements.
- if (this.comment?.__draftID) {
- resComment.__draftID = this.comment.__draftID;
- }
- if (!resComment.patch_set) resComment.patch_set = this.patchNum;
- this.comment = resComment;
- const details = this.commentDetailsForReporting();
- this.reporting.reportInteraction(Interaction.COMMENT_SAVED, details);
- this._fireSave();
- return obj;
- });
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
-
- return this._xhrPromise;
- }
-
- private commentDetailsForReporting() {
- return {
- id: this.comment?.id,
- message_length: this.comment?.message?.length,
- in_reply_to: this.comment?.in_reply_to,
- unresolved: this.comment?.unresolved,
- path_length: this.comment?.path?.length,
- line: this.comment?.range?.start_line ?? this.comment?.line,
- };
- }
-
- _eraseDraftCommentFromStorage() {
- // Prevents a race condition in which removing the draft comment occurs
- // prior to it being saved.
- this.storeTask?.cancel();
-
- assertIsDefined(this.comment?.path, 'comment.path');
- assertIsDefined(this.changeNum, 'changeNum');
- this.storage.eraseDraftComment({
- changeNum: this.changeNum,
- patchNum: this._getPatchNum(),
- path: this.comment.path,
- line: this.comment.line,
- range: this.comment.range,
- });
- }
-
- _commentChanged(comment: UIComment) {
- this.editing = isDraft(comment) && !!comment.__editing;
- this.resolved = !comment.unresolved;
- this.discarding = false;
- if (this.editing) {
- // It's a new draft/reply, notify.
- this._fireUpdate();
- }
- }
-
- @observe('comment', 'comments.*')
- _computeHasHumanReply() {
- const comment = this.comment;
- if (!comment || !this.comments) return;
- // hide please fix button for robot comment that has human reply
- this._hasHumanReply = this.comments.some(
- c =>
- c.in_reply_to &&
- c.in_reply_to === comment.id &&
- !(c as UIRobot).robot_id
- );
- }
-
- _getEventPayload(): OpenFixPreviewEventDetail {
- return {comment: this.comment, patchNum: this.patchNum};
- }
-
- _fireEdit() {
- if (this.comment) this.commentsService.editDraft(this.comment);
- this.dispatchEvent(
- new CustomEvent('comment-edit', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- }
-
- _fireSave() {
- if (this.comment) this.commentsService.addDraft(this.comment);
- this.dispatchEvent(
- new CustomEvent('comment-save', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- }
-
- _fireUpdate() {
- this.fireUpdateTask = debounce(this.fireUpdateTask, () => {
- this.dispatchEvent(
- new CustomEvent('comment-update', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- });
- }
-
- _computeAccountLabelClass(draft: boolean) {
- return draft ? 'draft' : '';
- }
-
- _draftChanged(draft: boolean) {
- this.$.container.classList.toggle('draft', draft);
- }
-
- _editingChanged(editing?: boolean, previousValue?: boolean) {
- // Polymer 2: observer fires when at least one property is defined.
- // Do nothing to prevent comment.__editing being overwritten
- // if previousValue is undefined
- if (previousValue === undefined) return;
-
- this.$.container.classList.toggle('editing', editing);
- if (this.comment && this.comment.id) {
- const cancelButton = this.shadowRoot?.querySelector(
- '.cancel'
- ) as GrButton | null;
- if (cancelButton) {
- cancelButton.hidden = !editing;
+ } else {
+ // No need to make a backend call when nothing has changed.
+ if (
+ messageToSave !== this.comment?.message ||
+ this.unresolved !== this.comment.unresolved
+ ) {
+ await this.rawSave(messageToSave, {showToast: true});
+ }
}
- }
- if (isDraft(this.comment)) {
- this.comment.__editing = this.editing;
- }
- if (!!editing !== !!previousValue) {
- // To prevent event firing on comment creation.
- this._fireUpdate();
- }
- if (editing) {
- setTimeout(() => {
- flush();
- this.textarea && this.textarea.putCursorAtEnd();
- }, 1);
- }
- }
-
- _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
- return isAdmin && !draft ? 'showDeleteButtons' : '';
- }
-
- _computeSaveDisabled(
- draft: string,
- comment: UIComment | undefined,
- resolved?: boolean
- ) {
- // If resolved state has changed and a msg exists, save should be enabled.
- if (!comment || (comment.unresolved === resolved && draft)) {
- return false;
- }
- return !draft || draft.trim() === '';
- }
-
- _handleSaveKey(e: Event) {
- if (
- !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
- ) {
- e.preventDefault();
- this._handleSave(e);
- }
- }
-
- _handleEsc(e: Event) {
- if (!this._messageText.length) {
- e.preventDefault();
- this._handleCancel(e);
- }
- }
-
- _handleToggleCollapsed() {
- this.collapsed = !this.collapsed;
- }
-
- _toggleCollapseClass(collapsed: boolean) {
- if (collapsed) {
- this.$.container.classList.add('collapsed');
- } else {
- this.$.container.classList.remove('collapsed');
- }
- }
-
- @observe('comment.message')
- _commentMessageChanged(message: string) {
- /*
- * Only overwrite the message text user has typed if there is no existing
- * text typed by the user. This prevents the bug where creating another
- * comment triggered a recomputation of comments and the text written by
- * the user was lost.
- */
- if (!this._messageText || !this.editing) this._messageText = message || '';
- }
-
- _messageTextChanged(_: string, oldValue: string) {
- // Only store comments that are being edited in local storage.
- if (
- !this.comment ||
- (this.comment.id && (!isDraft(this.comment) || !this.comment.__editing))
- ) {
- return;
- }
-
- const patchNum = this.comment.patch_set
- ? this.comment.patch_set
- : this._getPatchNum();
- const {path, line, range} = this.comment;
- if (!path) return;
- this.storeTask = debounce(
- this.storeTask,
- () => {
- const message = this._messageText;
- if (this.changeNum === undefined) {
- throw new Error('undefined changeNum');
- }
- const commentLocation: StorageLocation = {
- changeNum: this.changeNum,
- patchNum,
- path,
- line,
- range,
- };
-
- if ((!message || !message.length) && oldValue) {
- // If the draft has been modified to be empty, then erase the storage
- // entry.
- this.storage.eraseDraftComment(commentLocation);
- } else {
- this.storage.setDraftComment(commentLocation, message);
- }
- },
- STORAGE_DEBOUNCE_INTERVAL
- );
- }
-
- _handleAnchorClick(e: Event) {
- e.preventDefault();
- if (!this.comment) return;
- this.dispatchEvent(
- new CustomEvent('comment-anchor-tap', {
- bubbles: true,
- composed: true,
- detail: {
- number: this.comment.line || FILE,
- side: this.side,
- },
- })
- );
- }
-
- _handleEdit(e: Event) {
- e.preventDefault();
- if (this.comment?.message) this._messageText = this.comment.message;
- this.editing = true;
- this._fireEdit();
- this.reporting.recordDraftInteraction();
- }
-
- _handleSave(e: Event) {
- e.preventDefault();
-
- // Ignore saves started while already saving.
- if (this.disabled) return;
- const timingLabel = this.comment?.id
- ? REPORT_UPDATE_DRAFT
- : REPORT_CREATE_DRAFT;
- const timer = this.reporting.getTimer(timingLabel);
- this.set('comment.__editing', false);
- return this.save().then(() => {
- timer.end({id: this.comment?.id});
- });
- }
-
- _handleCancel(e: Event) {
- e.preventDefault();
- if (!this.comment) return;
- if (!this.comment.id) {
- // Ensures we update the discarded draft message before deleting the draft
- this.set('comment.message', this._messageText);
- this._fireDiscard();
- } else {
- this.set('comment.__editing', false);
- this.commentsService.cancelDraft(this.comment);
this.editing = false;
+ } catch (e) {
+ this.unableToSave = true;
+ throw e;
+ } finally {
+ this.saving = false;
}
}
- _fireDiscard() {
- if (this.comment) this.commentsService.deleteDraft(this.comment);
- this.fireUpdateTask?.cancel();
- this.dispatchEvent(
- new CustomEvent('comment-discard', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- }
-
- _handleFix() {
- this.dispatchEvent(
- new CustomEvent('create-fix-comment', {
- bubbles: true,
- composed: true,
- detail: this._getEventPayload(),
- })
- );
- }
-
- _handleShowFix() {
- this.dispatchEvent(
- new CustomEvent('open-fix-preview', {
- bubbles: true,
- composed: true,
- detail: this._getEventPayload(),
- })
- );
- }
-
- _hasNoFix(comment?: UIComment) {
- return !comment || !(comment as UIRobot).fix_suggestions;
- }
-
- _handleDiscard(e: Event) {
- e.preventDefault();
- this.reporting.recordDraftInteraction();
-
- this._discardDraft();
- }
-
- _discardDraft() {
- if (!this.comment) return Promise.reject(new Error('undefined comment'));
- if (!isDraft(this.comment)) {
- return Promise.reject(new Error('Cannot discard a non-draft comment.'));
- }
- this.discarding = true;
- const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
- this.editing = false;
- this.disabled = true;
- this._eraseDraftCommentFromStorage();
-
- if (!this.comment.id) {
- this.disabled = false;
- this._fireDiscard();
- return Promise.resolve();
- }
-
- this._xhrPromise = this._deleteDraft(this.comment)
- .then(response => {
- this.disabled = false;
- if (!response.ok) {
- this.discarding = false;
- }
- timer.end({id: this.comment?.id});
- this._fireDiscard();
- return response;
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
-
- return this._xhrPromise;
- }
-
- _getSavingMessage(numPending: number, requestFailed?: boolean) {
- if (requestFailed) {
- return UNSAVED_MESSAGE;
- }
- if (numPending === 0) {
- return SAVED_MESSAGE;
- }
- return `Saving ${pluralize(numPending, 'draft')}...`;
- }
-
- _showStartRequest() {
- const numPending = ++this._numPendingDraftRequests.number;
- this._updateRequestToast(numPending);
- }
-
- _showEndRequest() {
- const numPending = --this._numPendingDraftRequests.number;
- this._updateRequestToast(numPending);
- }
-
- _handleFailedDraftRequest() {
- this._numPendingDraftRequests.number--;
-
- // Cancel the debouncer so that error toasts from the error-manager will
- // not be overridden.
- this.draftToastTask?.cancel();
- this._updateRequestToast(
- this._numPendingDraftRequests.number,
- /* requestFailed=*/ true
- );
- }
-
- _updateRequestToast(numPending: number, requestFailed?: boolean) {
- const message = this._getSavingMessage(numPending, requestFailed);
- this.draftToastTask = debounce(
- this.draftToastTask,
- () => {
- // Note: the event is fired on the body rather than this element because
- // this element may not be attached by the time this executes, in which
- // case the event would not bubble.
- fireAlert(document.body, message);
+ /** For sharing between save() and autoSave(). */
+ private rawSave(message: string, options: {showToast: boolean}) {
+ if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+ return this.commentsModel.saveDraft(
+ {
+ ...this.comment,
+ message,
+ unresolved: this.unresolved,
},
- TOAST_DEBOUNCE_INTERVAL
+ options.showToast
);
}
- _handleDraftFailure() {
- this.$.container.classList.add('unableToSave');
- this._unableToSave = true;
- this._handleFailedDraftRequest();
+ private handleToggleResolved() {
+ this.unresolved = !this.unresolved;
+ if (!this.editing) this.save();
}
- _saveDraft(draft?: UIComment) {
- if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
- throw new Error('undefined draft or changeNum or patchNum');
- }
- this._showStartRequest();
- return this.restApiService
- .saveDiffDraft(this.changeNum, this.patchNum, draft)
- .then(result => {
- if (result.ok) {
- // remove
- this._unableToSave = false;
- this.$.container.classList.remove('unableToSave');
- this._showEndRequest();
- } else {
- this._handleDraftFailure();
- }
- return result;
- })
- .catch(err => {
- this._handleDraftFailure();
- throw err;
- });
+ private async openDeleteCommentOverlay() {
+ this.showConfirmDeleteOverlay = true;
+ await this.updateComplete;
+ await this.confirmDeleteOverlay?.open();
}
- _deleteDraft(draft: UIComment) {
- const changeNum = this.changeNum;
- const patchNum = this.patchNum;
- if (changeNum === undefined || patchNum === undefined) {
- throw new Error('undefined changeNum or patchNum');
- }
- fireAlert(this, 'Discarding draft...');
- const draftID = draft.id;
- if (!draftID) throw new Error('Missing id in comment draft.');
- return this.restApiService
- .deleteDiffDraft(changeNum, patchNum, {id: draftID})
- .then(result => {
- if (result.ok) {
- fire(this, 'show-alert', {
- message: 'Draft Discarded',
- action: 'Undo',
- callback: () =>
- this.commentsService.restoreDraft(changeNum, patchNum, draftID),
- });
- }
- return result;
- });
+ private closeDeleteCommentOverlay() {
+ this.showConfirmDeleteOverlay = false;
+ this.confirmDeleteOverlay?.remove();
+ this.confirmDeleteOverlay?.close();
}
- _getPatchNum(): PatchSetNum {
- const patchNum = this.isOnParent()
- ? ('PARENT' as BasePatchSetNum)
- : this.patchNum;
- if (patchNum === undefined) throw new Error('patchNum undefined');
- return patchNum;
- }
-
- @observe('changeNum', 'patchNum', 'comment')
- _loadLocalDraft(
- changeNum: number,
- patchNum?: PatchSetNum,
- comment?: UIComment
- ) {
- // Polymer 2: check for undefined
- if ([changeNum, patchNum, comment].includes(undefined)) {
- return;
- }
-
- // Only apply local drafts to comments that are drafts and are currently
- // being edited.
- if (
- !comment ||
- !comment.path ||
- comment.message ||
- !isDraft(comment) ||
- !comment.__editing
- ) {
- return;
- }
-
- const draft = this.storage.getDraftComment({
- changeNum,
- patchNum: this._getPatchNum(),
- path: comment.path,
- line: comment.line,
- range: comment.range,
- });
-
- if (draft) {
- this._messageText = draft.message || '';
- }
- }
-
- _handleToggleResolved() {
- this.reporting.recordDraftInteraction();
- this.resolved = !this.resolved;
- // Modify payload instead of this.comment, as this.comment is passed from
- // the parent by ref.
- const payload = this._getEventPayload();
- if (!payload.comment) {
- throw new Error('comment not defined in payload');
- }
- payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
- this.dispatchEvent(
- new CustomEvent('comment-update', {
- detail: payload,
- composed: true,
- bubbles: true,
- })
- );
- if (!this.editing) {
- // Save the resolved state immediately.
- this.save(payload.comment);
- }
- }
-
- _handleCommentDelete() {
- this._openOverlay(this.confirmDeleteOverlay);
- }
-
- _handleCancelDeleteComment() {
- this._closeOverlay(this.confirmDeleteOverlay);
- }
-
- _openOverlay(overlay?: GrOverlay | null) {
- if (!overlay) {
- return Promise.reject(new Error('undefined overlay'));
- }
- getRootElement().appendChild(overlay);
- return overlay.open();
- }
-
- _computeHideRunDetails(comment: UIComment | undefined, collapsed: boolean) {
- if (!comment) return true;
- if (!isRobot(comment)) return true;
- return !comment.url || collapsed;
- }
-
- _closeOverlay(overlay?: GrOverlay | null) {
- if (overlay) {
- getRootElement().removeChild(overlay);
- overlay.close();
- }
- }
-
- _handleConfirmDeleteComment() {
+ /**
+ * Deleting a *published* comment is an admin feature. It means more than just
+ * discarding a draft.
+ *
+ * TODO: Also move this into the comments-service.
+ * TODO: Figure out a good reloading strategy when deleting was successful.
+ * `this.comment = newComment` does not seem sufficient.
+ */
+ // private, but visible for testing
+ handleConfirmDeleteComment() {
const dialog = this.confirmDeleteOverlay?.querySelector(
'#confirmDeleteComment'
) as GrConfirmDeleteCommentDialog | null;
if (!dialog || !dialog.message) {
throw new Error('missing confirm delete dialog');
}
- if (
- !this.comment ||
- !this.comment.id ||
- this.changeNum === undefined ||
- this.patchNum === undefined
- ) {
- throw new Error('undefined comment or id or changeNum or patchNum');
+ assertIsDefined(this.changeNum, 'changeNum');
+ assertIsDefined(this.comment, 'comment');
+ assertIsDefined(this.comment.patch_set, 'comment.patch_set');
+ if (isDraftOrUnsaved(this.comment)) {
+ throw new Error('Admin deletion is only for published comments.');
}
this.restApiService
.deleteComment(
this.changeNum,
- this.patchNum,
+ this.comment.patch_set,
this.comment.id,
dialog.message
)
.then(newComment => {
- this._handleCancelDeleteComment();
+ this.closeDeleteCommentOverlay();
this.comment = newComment;
});
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
deleted file mode 100644
index b77c4b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- :host {
- display: block;
- font-family: var(--font-family);
- padding: var(--spacing-m);
- }
- :host([collapsed]) {
- padding: var(--spacing-s) var(--spacing-m);
- }
- :host([disabled]) {
- pointer-events: none;
- }
- :host([disabled]) .actions,
- :host([disabled]) .robotActions,
- :host([disabled]) .date {
- opacity: 0.5;
- }
- :host([discarding]) {
- display: none;
- }
- .body {
- padding-top: var(--spacing-m);
- }
- .header {
- align-items: center;
- cursor: pointer;
- display: flex;
- }
- .headerLeft > span {
- font-weight: var(--font-weight-bold);
- }
- .headerMiddle {
- color: var(--deemphasized-text-color);
- flex: 1;
- overflow: hidden;
- }
- .draftLabel,
- .draftTooltip {
- color: var(--deemphasized-text-color);
- display: none;
- }
- .date {
- justify-content: flex-end;
- text-align: right;
- white-space: nowrap;
- }
- span.date {
- color: var(--deemphasized-text-color);
- }
- span.date:hover {
- text-decoration: underline;
- }
- .actions,
- .robotActions {
- display: flex;
- justify-content: flex-end;
- padding-top: 0;
- }
- .robotActions {
- /* Better than the negative margin would be to remove the gr-button
- * padding, but then we would also need to fix the buttons that are
- * inserted by plugins. :-/ */
- margin: 4px 0 -4px;
- }
- .action {
- margin-left: var(--spacing-l);
- }
- .rightActions {
- display: flex;
- justify-content: flex-end;
- }
- .rightActions gr-button {
- --gr-button-padding: 0 var(--spacing-s);
- }
- .editMessage {
- display: none;
- margin: var(--spacing-m) 0;
- width: 100%;
- }
- .container:not(.draft) .actions .hideOnPublished {
- display: none;
- }
- .draft .reply,
- .draft .quote,
- .draft .ack,
- .draft .done {
- display: none;
- }
- .draft .draftLabel,
- .draft .draftTooltip {
- display: inline;
- }
- .draft:not(.editing):not(.unableToSave) .save,
- .draft:not(.editing) .cancel {
- display: none;
- }
- .editing .message,
- .editing .reply,
- .editing .quote,
- .editing .ack,
- .editing .done,
- .editing .edit,
- .editing .discard,
- .editing .unresolved {
- display: none;
- }
- .editing .editMessage {
- display: block;
- }
- .show-hide {
- margin-left: var(--spacing-s);
- }
- .robotId {
- color: var(--deemphasized-text-color);
- margin-bottom: var(--spacing-m);
- }
- .robotRun {
- margin-left: var(--spacing-m);
- }
- .robotRunLink {
- margin-left: var(--spacing-m);
- }
- input.show-hide {
- display: none;
- }
- label.show-hide {
- cursor: pointer;
- display: block;
- }
- label.show-hide iron-icon {
- vertical-align: top;
- }
- #container .collapsedContent {
- display: none;
- }
- #container.collapsed .body {
- padding-top: 0;
- }
- #container.collapsed .collapsedContent {
- display: block;
- overflow: hidden;
- padding-left: var(--spacing-m);
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- #container.collapsed #deleteBtn,
- #container.collapsed .date,
- #container.collapsed .actions,
- #container.collapsed gr-formatted-text,
- #container.collapsed gr-textarea,
- #container.collapsed .respectfulReviewTip {
- display: none;
- }
- .resolve,
- .unresolved {
- align-items: center;
- display: flex;
- flex: 1;
- margin: 0;
- }
- .resolve label {
- color: var(--comment-text-color);
- }
- gr-dialog .main {
- display: flex;
- flex-direction: column;
- width: 100%;
- }
- #deleteBtn {
- display: none;
- --gr-button-text-color: var(--deemphasized-text-color);
- --gr-button-padding: 0;
- }
- #deleteBtn.showDeleteButtons {
- display: block;
- }
-
- /** Disable select for the caret and actions */
- .actions,
- .show-hide {
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- }
-
- .respectfulReviewTip {
- justify-content: space-between;
- display: flex;
- padding: var(--spacing-m);
- border: 1px solid var(--border-color);
- border-radius: var(--border-radius);
- margin-bottom: var(--spacing-m);
- }
- .respectfulReviewTip div {
- display: flex;
- }
- .respectfulReviewTip div iron-icon {
- margin-right: var(--spacing-s);
- }
- .respectfulReviewTip a {
- white-space: nowrap;
- margin-right: var(--spacing-s);
- padding-left: var(--spacing-m);
- text-decoration: none;
- }
- .pointer {
- cursor: pointer;
- }
- .patchset-text {
- color: var(--deemphasized-text-color);
- margin-left: var(--spacing-s);
- }
- .headerLeft gr-account-label {
- --account-max-length: 130px;
- width: 150px;
- }
- .headerLeft gr-account-label::part(gr-account-label-text) {
- font-weight: var(--font-weight-bold);
- }
- .draft gr-account-label {
- width: unset;
- }
- .portedMessage {
- margin: 0 var(--spacing-m);
- }
- .link-icon {
- cursor: pointer;
- }
- </style>
- <div id="container" class="container">
- <div class="header" id="header" on-click="_handleToggleCollapsed">
- <div class="headerLeft">
- <template is="dom-if" if="[[comment.robot_id]]">
- <span class="robotName"> [[comment.robot_id]] </span>
- </template>
- <template is="dom-if" if="[[!comment.robot_id]]">
- <gr-account-label
- account="[[_getAuthor(comment, _selfAccount)]]"
- class$="[[_computeAccountLabelClass(draft)]]"
- hideStatus
- >
- </gr-account-label>
- </template>
- <template is="dom-if" if="[[showPortedComment]]">
- <a href="[[_getUrlForComment(comment)]]"
- ><span class="portedMessage" on-click="_handlePortedMessageClick"
- >From patchset [[comment.patch_set]]</span
- ></a
- >
- </template>
- <gr-tooltip-content
- class="draftTooltip"
- has-tooltip
- title="[[_computeDraftTooltip(_unableToSave)]]"
- max-width="20em"
- show-icon
- >
- <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
- </gr-tooltip-content>
- </div>
- <div class="headerMiddle">
- <span class="collapsedContent">[[comment.message]]</span>
- </div>
- <div
- hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
- class="runIdMessage message"
- >
- <div class="runIdInformation">
- <a class="robotRunLink" href$="[[comment.url]]">
- <span class="robotRun link">Run Details</span>
- </a>
- </div>
- </div>
- <gr-button
- id="deleteBtn"
- title="Delete Comment"
- link=""
- class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
- hidden$="[[isRobotComment]]"
- on-click="_handleCommentDelete"
- >
- <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
- </gr-button>
- <template is="dom-if" if="[[showPatchset]]">
- <span class="patchset-text"> Patchset [[patchNum]]</span>
- </template>
- <span class="separator"></span>
- <template is="dom-if" if="[[comment.updated]]">
- <span class="date" tabindex="0" on-click="_handleAnchorClick">
- <gr-date-formatter
- withTooltip
- date-str="[[comment.updated]]"
- ></gr-date-formatter>
- </span>
- </template>
- <div class="show-hide" tabindex="0">
- <label
- class="show-hide"
- aria-label$="[[_computeShowHideAriaLabel(collapsed)]]"
- >
- <input
- type="checkbox"
- class="show-hide"
- checked$="[[collapsed]]"
- on-change="_handleToggleCollapsed"
- />
- <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
- </iron-icon>
- </label>
- </div>
- </div>
- <div class="body">
- <template is="dom-if" if="[[isRobotComment]]">
- <div class="robotId" hidden$="[[collapsed]]">
- [[comment.author.name]]
- </div>
- </template>
- <template is="dom-if" if="[[editing]]">
- <gr-textarea
- id="editTextarea"
- class="editMessage"
- autocomplete="on"
- code=""
- disabled="{{disabled}}"
- rows="4"
- text="{{_messageText}}"
- ></gr-textarea>
- <template
- is="dom-if"
- if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
- >
- <div class="respectfulReviewTip">
- <div>
- <gr-tooltip-content
- has-tooltip
- title="Tips for respectful code reviews."
- >
- <iron-icon
- class="pointer"
- icon="gr-icons:lightbulb-outline"
- ></iron-icon>
- </gr-tooltip-content>
- [[_respectfulReviewTip]]
- </div>
- <div>
- <a
- tabindex="-1"
- on-click="_onRespectfulReadMoreClick"
- href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
- target="_blank"
- >
- Read more
- </a>
- <a
- tabindex="-1"
- class="close pointer"
- on-click="_dismissRespectfulTip"
- >Not helpful</a
- >
- </div>
- </div>
- </template>
- </template>
- <!--The message class is needed to ensure selectability from
- gr-diff-selection.-->
- <gr-formatted-text
- class="message"
- content="[[comment.message]]"
- no-trailing-margin="[[!comment.__draft]]"
- config="[[projectConfig.commentlinks]]"
- ></gr-formatted-text>
- <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
- <div class="action resolve hideOnPublished">
- <label>
- <input
- type="checkbox"
- id="resolvedCheckbox"
- checked="[[resolved]]"
- on-change="_handleToggleResolved"
- />
- Resolved
- </label>
- </div>
- <template is="dom-if" if="[[draft]]">
- <div class="rightActions">
- <template is="dom-if" if="[[hasPublishedComment(comments)]]">
- <iron-icon
- class="link-icon"
- on-click="handleCopyLink"
- class="copy"
- title="Copy link to this comment"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- >
- </iron-icon>
- </template>
- <gr-button
- link=""
- class="action cancel hideOnPublished"
- on-click="_handleCancel"
- >Cancel</gr-button
- >
- <gr-button
- link=""
- class="action discard hideOnPublished"
- on-click="_handleDiscard"
- >Discard</gr-button
- >
- <gr-button
- link=""
- class="action edit hideOnPublished"
- on-click="_handleEdit"
- >Edit</gr-button
- >
- <gr-button
- link=""
- disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
- class="action save hideOnPublished"
- on-click="_handleSave"
- >Save</gr-button
- >
- </div>
- </template>
- </div>
- <div class="robotActions" hidden$="[[!_showRobotActions]]">
- <template is="dom-if" if="[[hasPublishedComment(comments)]]">
- <iron-icon
- class="link-icon"
- on-click="handleCopyLink"
- class="copy"
- title="Copy link to this comment"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- >
- </iron-icon>
- </template>
- <template is="dom-if" if="[[isRobotComment]]">
- <gr-endpoint-decorator name="robot-comment-controls">
- <gr-endpoint-param name="comment" value="[[comment]]">
- </gr-endpoint-param>
- </gr-endpoint-decorator>
- <gr-button
- link=""
- secondary=""
- class="action show-fix"
- hidden$="[[_hasNoFix(comment)]]"
- on-click="_handleShowFix"
- >
- Show Fix
- </gr-button>
- <template is="dom-if" if="[[!_hasHumanReply]]">
- <gr-button
- link=""
- class="action fix"
- on-click="_handleFix"
- disabled="[[robotButtonDisabled]]"
- >
- Please Fix
- </gr-button>
- </template>
- </template>
- </div>
- </div>
- </div>
- <template is="dom-if" if="[[_enableOverlay]]">
- <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
- <gr-confirm-delete-comment-dialog
- id="confirmDeleteComment"
- on-confirm="_handleConfirmDeleteComment"
- on-cancel="_handleCancelDeleteComment"
- >
- </gr-confirm-delete-comment-dialog>
- </gr-overlay>
- </template>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 8e87676..28a52dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -14,1631 +14,663 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import '../../../test/common-test-setup-karma';
import './gr-comment';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
-import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {AUTO_SAVE_DEBOUNCE_DELAY_MS, GrComment} from './gr-comment';
import {
queryAndAssert,
stubRestApi,
stubStorage,
- spyStorage,
query,
- isVisible,
- stubReporting,
+ pressKey,
+ listenOnce,
+ stubComments,
mockPromise,
+ waitUntilCalled,
+ dispatch,
+ MockPromise,
} from '../../../test/test-utils';
import {
AccountId,
EmailAddress,
- FixId,
NumericChangeId,
- ParsedJSON,
PatchSetNum,
- RobotId,
- RobotRunId,
Timestamp,
UrlEncodedCommentId,
} from '../../../types/common';
-import {
- pressAndReleaseKeyOn,
- tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
import {
createComment,
createDraft,
createFixSuggestionInfo,
+ createRobotComment,
} from '../../../test/test-data-generators';
-import {Timer} from '../../../services/gr-reporting/gr-reporting';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {CreateFixCommentEvent} from '../../../types/events';
-import {DraftInfo, UIRobot} from '../../../utils/comment-util';
-import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {
+ CreateFixCommentEvent,
+ OpenFixPreviewEventDetail,
+} from '../../../types/events';
import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
- <gr-comment draft="true"></gr-comment>
-`);
+import {DraftInfo} from '../../../utils/comment-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Modifier} from '../../../utils/dom-util';
+import {SinonStub} from 'sinon';
suite('gr-comment tests', () => {
- suite('basic tests', () => {
- let element: GrComment;
+ let element: GrComment;
- let openOverlaySpy: sinon.SinonSpy;
+ setup(() => {
+ element = fixtureFromElement('gr-comment').instantiate();
+ element.account = {
+ email: 'dhruvsri@google.com' as EmailAddress,
+ name: 'Dhruv Srivastava',
+ _account_id: 1083225 as AccountId,
+ avatars: [{url: 'abc', height: 32, width: 32}],
+ registered_on: '123' as Timestamp,
+ };
+ element.showPatchset = true;
+ element.getRandomInt = () => 1;
+ element.comment = {
+ ...createComment(),
+ author: {
+ name: 'Mr. Peanutbutter',
+ email: 'tenn1sballchaser@aol.com' as EmailAddress,
+ },
+ id: 'baf0414d_60047215' as UrlEncodedCommentId,
+ line: 5,
+ message: 'This is the test comment message.',
+ updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+ };
+ });
- setup(() => {
- stubRestApi('getAccount').returns(
- Promise.resolve({
- email: 'dhruvsri@google.com' as EmailAddress,
- name: 'Dhruv Srivastava',
- _account_id: 1083225 as AccountId,
- avatars: [{url: 'abc', height: 32, width: 32}],
- registered_on: '123' as Timestamp,
- })
- );
- element = basicFixture.instantiate();
- element.getRandomNum = () => 1;
- element.comment = {
- ...createComment(),
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- line: 5,
- message: 'is this a crossover episode!?',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- };
-
- openOverlaySpy = sinon.spy(element, '_openOverlay');
- });
-
- teardown(() => {
- openOverlaySpy.getCalls().forEach(call => {
- call.args[0].remove();
- });
- });
-
- test('renders', async () => {
- await flush();
+ suite('DOM rendering', () => {
+ test('renders collapsed', async () => {
+ element.initiallyCollapsed = true;
+ await element.updateComplete;
expect(element).shadowDom.to.equal(`
- <div class="collapsed container" id="container">
+ <div class="container" id="container">
<div class="header" id="header">
<div class="headerLeft">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
<gr-account-label deselected="" hidestatus=""></gr-account-label>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-tooltip-content
- class="draftTooltip"
- has-tooltip=""
- max-width="20em"
- show-icon=""
- title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
- >
- <span class="draftLabel">DRAFT</span>
- </gr-tooltip-content>
</div>
<div class="headerMiddle">
<span class="collapsedContent">
- is this a crossover episode!?
+ This is the test comment message.
</span>
</div>
- <div class="message runIdMessage" hidden="true">
- <div class="runIdInformation">
- <a class="robotRunLink">
- <span class="link robotRun">
- Run Details
- </span>
- </a>
- </div>
+ <span class="patchset-text">Patchset 1</span>
+ <div class="show-hide" tabindex="0">
+ <label aria-label="Expand" class="show-hide">
+ <input checked="" class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-more"></iron-icon>
+ </label>
</div>
- <gr-button
- aria-disabled="false"
- class="action delete"
- id="deleteBtn"
- link=""
- role="button"
- tabindex="0"
- title="Delete Comment"
- >
- <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
- </gr-button>
- <span class="patchset-text">Patchset</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ </div>
+ <div class="body"></div>
+ </div>
+ `);
+ });
+
+ test('renders expanded', async () => {
+ element.initiallyCollapsed = false;
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="container" id="container">
+ <div class="header" id="header">
+ <div class="headerLeft">
+ <gr-account-label deselected="" hidestatus=""></gr-account-label>
+ </div>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
<span class="separator"></span>
<span class="date" tabindex="0">
<gr-date-formatter withtooltip=""></gr-date-formatter>
</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
<div class="show-hide" tabindex="0">
- <label aria-label="Expand" class="show-hide">
- <input checked="true" class="show-hide" type="checkbox">
- <iron-icon id="icon"></iron-icon>
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
</label>
</div>
</div>
<div class="body">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-formatted-text class="message" notrailingmargin="">
- </gr-formatted-text>
- <div class="actions humanActions">
- <div class="action hideOnPublished resolve">
- <label>
- <input id="resolvedCheckbox" type="checkbox">
- Resolved
- </label>
- </div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- </div>
- <div class="robotActions">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- </div>
+ <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
</div>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
`);
});
- test('renders editing:true', async () => {
+ test('renders expanded robot', async () => {
+ element.initiallyCollapsed = false;
+ element.comment = createRobotComment();
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="container" id="container">
+ <div class="header" id="header">
+ <div class="headerLeft">
+ <span class="robotName">robot-id-123</span>
+ </div>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
+ <span class="separator"></span>
+ <span class="date" tabindex="0">
+ <gr-date-formatter withtooltip=""></gr-date-formatter>
+ </span>
+ <div class="show-hide" tabindex="0">
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+ </label>
+ </div>
+ </div>
+ <div class="body">
+ <div class="robotId"></div>
+ <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
+ <div class="robotActions">
+ <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0"
+ title="Copy link to this comment">
+ </iron-icon>
+ <gr-endpoint-decorator name="robot-comment-controls">
+ <gr-endpoint-param name="comment"></gr-endpoint-param>
+ </gr-endpoint-decorator>
+ <gr-button aria-disabled="false" class="action show-fix" link="" role="button" secondary="" tabindex="0">
+ Show Fix
+ </gr-button>
+ <gr-button aria-disabled="false" class="action fix" link="" role="button" tabindex="0">
+ Please Fix
+ </gr-button>
+ </div>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders expanded admin', async () => {
+ element.initiallyCollapsed = false;
+ element.isAdmin = true;
+ await element.updateComplete;
+ expect(queryAndAssert(element, 'gr-button.delete')).dom.to.equal(`
+ <gr-button
+ aria-disabled="false"
+ class="action delete"
+ id="deleteBtn"
+ link=""
+ role="button"
+ tabindex="0"
+ title="Delete Comment"
+ >
+ <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
+ </gr-button>
+ `);
+ });
+
+ test('renders draft', async () => {
+ element.initiallyCollapsed = false;
+ (element.comment as DraftInfo).__draft = true;
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="container draft" id="container">
+ <div class="header" id="header">
+ <div class="headerLeft">
+ <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
+ <gr-tooltip-content
+ class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+ title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+ >
+ <span class="draftLabel">DRAFT</span>
+ </gr-tooltip-content>
+ </div>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
+ <span class="separator"></span>
+ <span class="date" tabindex="0">
+ <gr-date-formatter withtooltip=""></gr-date-formatter>
+ </span>
+ <div class="show-hide" tabindex="0">
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+ </label>
+ </div>
+ </div>
+ <div class="body">
+ <gr-formatted-text class="message"></gr-formatted-text>
+ <div class="actions">
+ <div class="action resolve">
+ <label>
+ <input checked="" id="resolvedCheckbox" type="checkbox">
+ Resolved
+ </label>
+ </div>
+ <div class="rightActions">
+ <gr-button aria-disabled="false" class="action discard" link="" role="button" tabindex="0">
+ Discard
+ </gr-button>
+ <gr-button aria-disabled="false" class="action edit" link="" role="button" tabindex="0">
+ Edit
+ </gr-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders draft in editing mode', async () => {
+ element.initiallyCollapsed = false;
+ (element.comment as DraftInfo).__draft = true;
element.editing = true;
- await flush();
+ await element.updateComplete;
expect(element).shadowDom.to.equal(`
- <div class="collapsed container editing" id="container">
+ <div class="container draft" id="container">
<div class="header" id="header">
<div class="headerLeft">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-account-label deselected="" hidestatus=""></gr-account-label>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
<gr-tooltip-content
- class="draftTooltip"
- has-tooltip=""
- max-width="20em"
- show-icon=""
- title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
+ class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+ title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
>
<span class="draftLabel">DRAFT</span>
</gr-tooltip-content>
</div>
- <div class="headerMiddle">
- <span class="collapsedContent">
- is this a crossover episode!?
- </span>
- </div>
- <div class="message runIdMessage" hidden="true">
- <div class="runIdInformation">
- <a class="robotRunLink">
- <span class="link robotRun">
- Run Details
- </span>
- </a>
- </div>
- </div>
- <gr-button
- aria-disabled="false"
- class="action delete"
- id="deleteBtn"
- link=""
- role="button"
- tabindex="0"
- title="Delete Comment"
- >
- <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
- </gr-button>
- <span class="patchset-text">Patchset</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
<span class="separator"></span>
<span class="date" tabindex="0">
<gr-date-formatter withtooltip=""></gr-date-formatter>
</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
<div class="show-hide" tabindex="0">
- <label aria-label="Expand" class="show-hide">
- <input checked="true" class="show-hide" type="checkbox">
- <iron-icon id="icon"></iron-icon>
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
</label>
</div>
</div>
<div class="body">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-textarea autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4">
+ <gr-textarea
+ autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4"
+ text="This is the test comment message."
+ >
</gr-textarea>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-formatted-text class="message" notrailingmargin="">
- </gr-formatted-text>
- <div class="actions humanActions">
- <div class="action hideOnPublished resolve">
+ <div class="actions">
+ <div class="action resolve">
<label>
- <input id="resolvedCheckbox" type="checkbox">
+ <input checked="" id="resolvedCheckbox" type="checkbox">
Resolved
</label>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- </div>
- <div class="robotActions">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ <div class="rightActions">
+ <gr-button aria-disabled="false" class="action cancel" link="" role="button" tabindex="0">
+ Cancel
+ </gr-button>
+ <gr-button aria-disabled="false" class="action save" link="" role="button" tabindex="0">
+ Save
+ </gr-button>
+ </div>
</div>
</div>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
`);
});
-
- test('clicking on date link fires event', () => {
- element.side = 'PARENT';
- const stub = sinon.stub();
- element.addEventListener('comment-anchor-tap', stub);
- flush();
- const dateEl = queryAndAssert(element, '.date');
- assert.ok(dateEl);
- tap(dateEl);
-
- assert.isTrue(stub.called);
- assert.deepEqual(stub.lastCall.args[0].detail, {
- side: element.side,
- number: element.comment!.line,
- });
- });
-
- test('message is not retrieved from storage when missing path', async () => {
- const storageStub = stubStorage('getDraftComment');
- const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- };
- await flush();
- assert.isTrue(loadSpy.called);
- assert.isFalse(storageStub.called);
- });
-
- test('message is not retrieved from storage when message present', async () => {
- const storageStub = stubStorage('getDraftComment');
- const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- message: 'This is a message',
- line: 5,
- path: 'test',
- __editing: true,
- __draft: true,
- };
- await flush();
- assert.isTrue(loadSpy.called);
- assert.isFalse(storageStub.called);
- });
-
- test('message is retrieved from storage for drafts in edit', async () => {
- const storageStub = stubStorage('getDraftComment');
- const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- path: 'test',
- __editing: true,
- __draft: true,
- };
- await flush();
- assert.isTrue(loadSpy.called);
- assert.isTrue(storageStub.called);
- });
-
- test('comment message sets messageText only when empty', () => {
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element._messageText = '';
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- path: 'test',
- __editing: true,
- __draft: true,
- message: 'hello world',
- };
- // messageText was empty so overwrite the message now
- assert.equal(element._messageText, 'hello world');
-
- element.comment!.message = 'new message';
- // messageText was already set so do not overwrite it
- assert.equal(element._messageText, 'hello world');
- });
-
- test('comment message sets messageText when not edited', () => {
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element._messageText = 'Some text';
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- path: 'test',
- __editing: false,
- __draft: true,
- message: 'hello world',
- };
- // messageText was empty so overwrite the message now
- assert.equal(element._messageText, 'hello world');
-
- element.comment!.message = 'new message';
- // messageText was already set so do not overwrite it
- assert.equal(element._messageText, 'hello world');
- });
-
- test('_getPatchNum', () => {
- element.side = 'PARENT';
- element.patchNum = 1 as PatchSetNum;
- assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
- element.side = 'REVISION';
- assert.equal(element._getPatchNum(), 1 as PatchSetNum);
- });
-
- test('comment expand and collapse', () => {
- element.collapsed = true;
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are not visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isTrue(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is visible'
- );
-
- element.collapsed = false;
- assert.isFalse(element.collapsed);
- assert.isTrue(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is is not visible'
- );
- });
-
- suite('while editing', () => {
- let handleCancelStub: sinon.SinonStub;
- let handleSaveStub: sinon.SinonStub;
- setup(() => {
- element.editing = true;
- element._messageText = 'test';
- handleCancelStub = sinon.stub(element, '_handleCancel');
- handleSaveStub = sinon.stub(element, '_handleSave');
- flush();
- });
-
- suite('when text is empty', () => {
- setup(() => {
- element._messageText = '';
- element.comment = {};
- });
-
- test('esc closes comment when text is empty', () => {
- pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
- assert.isTrue(handleCancelStub.called);
- });
-
- test('ctrl+enter does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
- assert.isFalse(handleSaveStub.called);
- });
-
- test('meta+enter does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
- assert.isFalse(handleSaveStub.called);
- });
-
- test('ctrl+s does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
- assert.isFalse(handleSaveStub.called);
- });
- });
-
- test('esc does not close comment that has content', () => {
- pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
- assert.isFalse(handleCancelStub.called);
- });
-
- test('ctrl+enter saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
- assert.isTrue(handleSaveStub.called);
- });
-
- test('meta+enter saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
- assert.isTrue(handleSaveStub.called);
- });
-
- test('ctrl+s saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
- assert.isTrue(handleSaveStub.called);
- });
- });
-
- test('delete comment button for non-admins is hidden', () => {
- element._isAdmin = false;
- assert.isFalse(
- queryAndAssert(element, '.action.delete').classList.contains(
- 'showDeleteButtons'
- )
- );
- });
-
- test('delete comment button for admins with draft is hidden', () => {
- element._isAdmin = false;
- element.draft = true;
- assert.isFalse(
- queryAndAssert(element, '.action.delete').classList.contains(
- 'showDeleteButtons'
- )
- );
- });
-
- test('delete comment', async () => {
- const stub = stubRestApi('deleteComment').returns(
- Promise.resolve(createComment())
- );
- const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element._isAdmin = true;
- assert.isTrue(
- queryAndAssert(element, '.action.delete').classList.contains(
- 'showDeleteButtons'
- )
- );
- tap(queryAndAssert(element, '.action.delete'));
- await flush();
- await openSpy.lastCall.returnValue;
- const dialog = element.confirmDeleteOverlay?.querySelector(
- '#confirmDeleteComment'
- ) as GrConfirmDeleteCommentDialog;
- dialog.message = 'removal reason';
- element._handleConfirmDeleteComment();
- assert.isTrue(
- stub.calledWith(
- 42 as NumericChangeId,
- 1 as PatchSetNum,
- 'baf0414d_60047215' as UrlEncodedCommentId,
- 'removal reason'
- )
- );
- });
-
- suite('draft update reporting', () => {
- let endStub: SinonStubbedMember<() => Timer>;
- let getTimerStub: sinon.SinonStub;
- const mockEvent = {...new Event('click'), preventDefault() {}};
-
- setup(() => {
- sinon.stub(element, 'save').returns(Promise.resolve({}));
- endStub = sinon.stub();
- const mockTimer = new MockTimer();
- mockTimer.end = endStub;
- getTimerStub = stubReporting('getTimer').returns(mockTimer);
- });
-
- test('create', async () => {
- element.patchNum = 1 as PatchSetNum;
- element.comment = {};
- sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
- await element._handleSave(mockEvent);
- await flush();
- const grAccountLabel = queryAndAssert(element, 'gr-account-label');
- const spanName = queryAndAssert<HTMLSpanElement>(
- grAccountLabel,
- 'span.name'
- );
- assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
- assert.isTrue(endStub.calledOnce);
- assert.isTrue(getTimerStub.calledOnce);
- assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
- });
-
- test('update', () => {
- element.comment = {
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
- };
- sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
- return element._handleSave(mockEvent)!.then(() => {
- assert.isTrue(endStub.calledOnce);
- assert.isTrue(getTimerStub.calledOnce);
- assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
- });
- });
-
- test('discard', () => {
- element.comment = {
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
- };
- element.comment = createDraft();
- sinon.stub(element, '_fireDiscard');
- sinon.stub(element, '_eraseDraftCommentFromStorage');
- sinon
- .stub(element, '_deleteDraft')
- .returns(Promise.resolve(new Response()));
- return element._discardDraft().then(() => {
- assert.isTrue(endStub.calledOnce);
- assert.isTrue(getTimerStub.calledOnce);
- assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
- });
- });
- });
-
- test('edit reports interaction', () => {
- const reportStub = stubReporting('recordDraftInteraction');
- sinon.stub(element, '_fireEdit');
- element.draft = true;
- flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('discard reports interaction', () => {
- const reportStub = stubReporting('recordDraftInteraction');
- sinon.stub(element, '_eraseDraftCommentFromStorage');
- sinon.stub(element, '_fireDiscard');
- sinon
- .stub(element, '_deleteDraft')
- .returns(Promise.resolve(new Response()));
- element.draft = true;
- element.comment = createDraft();
- flush();
- tap(queryAndAssert(element, '.discard'));
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('failed save draft request', async () => {
- element.draft = true;
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const updateRequestStub = sinon.stub(element, '_updateRequestToast');
- const diffDraftStub = stubRestApi('saveDiffDraft').returns(
- Promise.resolve({...new Response(), ok: false})
- );
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- let args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0, true]);
- assert.equal(
- element._getSavingMessage(...args),
- __testOnly_UNSAVED_MESSAGE
- );
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT(Failed to save)'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.save')),
- 'save is visible'
- );
- diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0]);
- assert.equal(element._getSavingMessage(...args), 'All changes saved');
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(element._unableToSave);
- });
-
- test('failed save draft request with promise failure', async () => {
- element.draft = true;
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const updateRequestStub = sinon.stub(element, '_updateRequestToast');
- const diffDraftStub = stubRestApi('saveDiffDraft').returns(
- Promise.reject(new Error())
- );
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- let args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0, true]);
- assert.equal(
- element._getSavingMessage(...args),
- __testOnly_UNSAVED_MESSAGE
- );
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT(Failed to save)'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.save')),
- 'save is visible'
- );
- diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0]);
- assert.equal(element._getSavingMessage(...args), 'All changes saved');
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(element._unableToSave);
- });
});
- suite('gr-comment draft tests', () => {
- let element: GrComment;
+ test('clicking on date link fires event', async () => {
+ const stub = sinon.stub();
+ element.addEventListener('comment-anchor-tap', stub);
+ await element.updateComplete;
- setup(() => {
- stubRestApi('getAccount').returns(Promise.resolve(undefined));
- stubRestApi('saveDiffDraft').returns(
- Promise.resolve({
- ...new Response(),
- ok: true,
- text() {
- return Promise.resolve(
- ")]}'\n{" +
- '"id": "baf0414d_40572e03",' +
- '"path": "/path/to/file",' +
- '"line": 5,' +
- '"updated": "2015-12-08 21:52:36.177000000",' +
- '"message": "saved!",' +
- '"side": "REVISION",' +
- '"unresolved": false,' +
- '"patch_set": 1' +
- '}'
- );
- },
- })
- );
- stubRestApi('removeChangeReviewer').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- element = draftFixture.instantiate() as GrComment;
- stubStorage('getDraftComment').returns(null);
+ const dateEl = queryAndAssert(element, '.date');
+ tap(dateEl);
+
+ assert.isTrue(stub.called);
+ assert.deepEqual(stub.lastCall.args[0].detail, {
+ side: 'REVISION',
+ number: element.comment!.line,
+ });
+ });
+
+ test('comment message sets messageText only when empty', async () => {
+ element.changeNum = 1 as NumericChangeId;
+ element.messageText = '';
+ element.comment = {
+ ...createComment(),
+ author: {
+ name: 'Mr. Peanutbutter',
+ email: 'tenn1sballchaser@aol.com' as EmailAddress,
+ },
+ line: 5,
+ path: 'test',
+ __draft: true,
+ message: 'hello world',
+ };
+ element.editing = true;
+ await element.updateComplete;
+ // messageText was empty so overwrite the message now
+ assert.equal(element.messageText, 'hello world');
+
+ element.comment!.message = 'new message';
+ await element.updateComplete;
+ // messageText was already set so do not overwrite it
+ assert.equal(element.messageText, 'hello world');
+ });
+
+ test('comment message sets messageText when not edited', async () => {
+ element.changeNum = 1 as NumericChangeId;
+ element.messageText = 'Some text';
+ element.comment = {
+ ...createComment(),
+ author: {
+ name: 'Mr. Peanutbutter',
+ email: 'tenn1sballchaser@aol.com' as EmailAddress,
+ },
+ line: 5,
+ path: 'test',
+ __draft: true,
+ message: 'hello world',
+ };
+ element.editing = true;
+ await element.updateComplete;
+ // messageText was empty so overwrite the message now
+ assert.equal(element.messageText, 'hello world');
+
+ element.comment!.message = 'new message';
+ await element.updateComplete;
+ // messageText was already set so do not overwrite it
+ assert.equal(element.messageText, 'hello world');
+ });
+
+ test('delete comment', async () => {
+ element.changeNum = 42 as NumericChangeId;
+ element.isAdmin = true;
+ await element.updateComplete;
+
+ const deleteButton = queryAndAssert(element, '.action.delete');
+ tap(deleteButton);
+ await element.updateComplete;
+
+ assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
+ const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
+ element.confirmDeleteOverlay,
+ '#confirmDeleteComment'
+ );
+ dialog.message = 'removal reason';
+ await element.updateComplete;
+
+ const stub = stubRestApi('deleteComment').returns(
+ Promise.resolve(createComment())
+ );
+ element.handleConfirmDeleteComment();
+ assert.isTrue(
+ stub.calledWith(
+ 42 as NumericChangeId,
+ 1 as PatchSetNum,
+ 'baf0414d_60047215' as UrlEncodedCommentId,
+ 'removal reason'
+ )
+ );
+ });
+
+ suite('gr-comment draft tests', () => {
+ setup(async () => {
element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.editing = false;
element.comment = {
...createComment(),
__draft: true,
- __draftID: 'temp_draft_id',
path: '/path/to/file',
line: 5,
- id: undefined,
};
});
- test('button visibility states', async () => {
- element.showActions = false;
- assert.isTrue(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
+ test('isSaveDisabled', async () => {
+ element.saving = false;
+ element.unresolved = true;
+ element.comment = {...createComment(), unresolved: true};
+ element.messageText = 'asdf';
+ await element.updateComplete;
+ assert.isFalse(element.isSaveDisabled());
- element.showActions = true;
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
+ element.messageText = '';
+ await element.updateComplete;
+ assert.isTrue(element.isSaveDisabled());
- element.draft = true;
- await flush();
- assert.isTrue(
- isVisible(queryAndAssert(element, '.edit')),
- 'edit is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.discard')),
- 'discard is visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.resolve')),
- 'resolve is visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
+ element.unresolved = false;
+ await element.updateComplete;
+ assert.isFalse(element.isSaveDisabled());
- element.editing = true;
- await flush();
- assert.isFalse(
- isVisible(queryAndAssert(element, '.edit')),
- 'edit is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.discard')),
- 'discard not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.save')),
- 'save is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.resolve')),
- 'resolve is visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- element.draft = false;
- element.editing = false;
- await flush();
- assert.isFalse(
- isVisible(queryAndAssert(element, '.edit')),
- 'edit is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.discard')),
- 'discard is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is not visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- element.comment!.id = 'foo' as UrlEncodedCommentId;
- element.draft = true;
- element.editing = true;
- await flush();
- assert.isTrue(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- // Delete button is not hidden by default
- assert.isFalse(
- (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
- );
-
- element.isRobotComment = true;
- element.draft = true;
- assert.isTrue(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isFalse(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- // It is not expected to see Robot comment drafts, but if they appear,
- // they will behave the same as non-drafts.
- element.draft = false;
- assert.isTrue(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isFalse(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- // A robot comment with run ID should display plain text.
- element.set(['comment', 'robot_run_id'], 'text');
- element.editing = false;
- element.collapsed = false;
- await flush();
- assert.isTrue(
- queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
- );
-
- // A robot comment with run ID and url should display a link.
- element.set(['comment', 'url'], '/path/to/run');
- await flush();
- assert.notEqual(
- getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
- 'none'
- );
-
- // Delete button is hidden for robot comments
- assert.isTrue(
- (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
- );
- });
-
- test('collapsible drafts', async () => {
- const fireEditStub = sinon.stub(element, '_fireEdit');
- assert.isTrue(element.collapsed);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are not visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isTrue(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is visible'
- );
-
- tap(element.$.header);
- assert.isFalse(element.collapsed);
- assert.isTrue(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is is not visible'
- );
-
- // When the edit button is pressed, should still see the actions
- // and also textarea
- element.draft = true;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- await flush();
- assert.isTrue(fireEditStub.called);
- assert.isFalse(element.collapsed);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is not visible'
- );
-
- // When toggle again, everything should be hidden except for textarea
- // and header middle content should be visible
- tap(element.$.header);
- assert.isTrue(element.collapsed);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-textarea')),
- 'textarea is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is visible'
- );
-
- // When toggle again, textarea should remain open in the state it was
- // before
- tap(element.$.header);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is not visible'
- );
- });
-
- test('robot comment layout', async () => {
- const comment = {
- robot_id: 'happy_robot_id' as RobotId,
- url: '/robot/comment',
- author: {
- name: 'Happy Robot',
- display_name: 'Display name Robot',
- },
- ...element.comment,
- };
- element.comment = comment;
- element.collapsed = false;
- await flush;
- let runIdMessage;
- runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
- assert.isFalse((runIdMessage as HTMLElement).hidden);
-
- const runDetailsLink = queryAndAssert(
- element,
- '.robotRunLink'
- ) as HTMLAnchorElement;
- assert.isTrue(
- runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
- );
-
- const robotServiceName = queryAndAssert(element, '.robotName');
- assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
-
- const authorName = queryAndAssert(element, '.robotId');
- assert.isTrue((authorName as HTMLDivElement).innerText === 'Happy Robot');
-
- element.collapsed = true;
- await flush();
- runIdMessage = queryAndAssert(element, '.runIdMessage');
- assert.isTrue((runIdMessage as HTMLDivElement).hidden);
- });
-
- test('author name fallback to email', async () => {
- const comment = {
- url: '/robot/comment',
- author: {
- email: 'test@test.com' as EmailAddress,
- },
- ...element.comment,
- };
- element.comment = comment;
- element.collapsed = false;
- await flush();
- const authorName = queryAndAssert(
- queryAndAssert(element, 'gr-account-label'),
- 'span.name'
- ) as HTMLSpanElement;
- assert.equal(authorName.innerText.trim(), 'test@test.com');
- });
-
- test('patchset level comment', async () => {
- const fireEditStub = sinon.stub(element, '_fireEdit');
- const comment = {
- ...element.comment,
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- line: undefined,
- range: undefined,
- };
- element.comment = comment;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.called);
- assert.isTrue(element.editing);
-
- element._messageText = 'hello world';
- const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
- const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
- element._handleSave(mockEvent);
- await flush();
- assert.isTrue(eraseMessageDraftSpy.called);
- });
-
- test('draft creation/cancellation', async () => {
- const fireEditStub = sinon.stub(element, '_fireEdit');
- assert.isFalse(element.editing);
- element.draft = true;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.called);
- assert.isTrue(element.editing);
-
- element.comment!.message = '';
- element._messageText = '';
- const eraseMessageDraftSpy = sinon.spy(
- element,
- '_eraseDraftCommentFromStorage'
- );
-
- // Save should be disabled on an empty message.
- let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
- assert.isTrue(disabled, 'save button should be disabled.');
- element._messageText = ' ';
- disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
- assert.isTrue(disabled, 'save button should be disabled.');
-
- const updateStub = sinon.stub();
- element.addEventListener('comment-update', updateStub);
-
- let numDiscardEvents = 0;
- const promise = mockPromise();
- element.addEventListener('comment-discard', () => {
- numDiscardEvents++;
- assert.isFalse(eraseMessageDraftSpy.called);
- if (numDiscardEvents === 2) {
- assert.isFalse(updateStub.called);
- promise.resolve();
- }
- });
- tap(queryAndAssert(element, '.cancel'));
- await flush();
- element._messageText = '';
- element.editing = true;
- await flush();
- pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
- await promise;
- });
-
- test('draft discard removes message from storage', async () => {
- element._messageText = '';
- const eraseMessageDraftSpy = sinon.spy(
- element,
- '_eraseDraftCommentFromStorage'
- );
-
- const promise = mockPromise();
- element.addEventListener('comment-discard', () => {
- assert.isTrue(eraseMessageDraftSpy.called);
- promise.resolve();
- });
- element._handleDiscard({
- ...new Event('click'),
- preventDefault: sinon.stub(),
- });
- await promise;
- });
-
- test('storage is cleared only after save success', () => {
- element._messageText = 'test';
- const eraseStub = sinon.stub(element, '_eraseDraftCommentFromStorage');
- stubRestApi('getResponseObject').returns(
- Promise.resolve({...(createDraft() as ParsedJSON)})
- );
- const saveDraftStub = sinon
- .stub(element, '_saveDraft')
- .returns(Promise.resolve({...new Response(), ok: false}));
-
- const savePromise = element.save();
- assert.isFalse(eraseStub.called);
- return savePromise.then(() => {
- assert.isFalse(eraseStub.called);
-
- saveDraftStub.restore();
- sinon
- .stub(element, '_saveDraft')
- .returns(Promise.resolve({...new Response(), ok: true}));
- return element.save().then(() => {
- assert.isTrue(eraseStub.called);
- });
- });
- });
-
- test('_computeSaveDisabled', () => {
- const comment = {unresolved: true};
- const msgComment = {message: 'test', unresolved: true};
- assert.equal(element._computeSaveDisabled('', comment, false), true);
- assert.equal(element._computeSaveDisabled('test', comment, false), false);
- assert.equal(element._computeSaveDisabled('', msgComment, false), true);
- assert.equal(
- element._computeSaveDisabled('test', msgComment, false),
- false
- );
- assert.equal(
- element._computeSaveDisabled('test2', msgComment, false),
- false
- );
- assert.equal(element._computeSaveDisabled('test', comment, true), false);
- assert.equal(element._computeSaveDisabled('', comment, true), true);
- assert.equal(element._computeSaveDisabled('', comment, false), true);
+ element.saving = true;
+ await element.updateComplete;
+ assert.isTrue(element.isSaveDisabled());
});
test('ctrl+s saves comment', async () => {
- const promise = mockPromise();
- const stub = sinon.stub(element, 'save').callsFake(() => {
- assert.isTrue(stub.called);
- stub.restore();
- promise.resolve();
- return Promise.resolve();
- });
- element._messageText = 'is that the horse from horsing around??';
+ const spy = sinon.stub(element, 'save');
+ element.messageText = 'is that the horse from horsing around??';
element.editing = true;
- await flush();
- pressAndReleaseKeyOn(
- element.textarea!.$.textarea.textarea,
- 83,
- 'ctrl',
- 's'
- );
- await promise;
+ await element.updateComplete;
+ pressKey(element.textarea!.$.textarea.textarea, 's', Modifier.CTRL_KEY);
+ assert.isTrue(spy.called);
});
- test('draft saving/editing', async () => {
- const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
- const fireEditStub = sinon.stub(element, '_fireEdit');
- const clock: SinonFakeTimers = sinon.useFakeTimers();
- const tickAndFlush = async (repetitions: number) => {
- for (let i = 1; i <= repetitions; i++) {
- clock.tick(1000);
- await flush();
- }
- };
+ test('save', async () => {
+ const savePromise = mockPromise<void>();
+ const stub = stubComments('saveDraft').returns(savePromise);
- element.draft = true;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.called);
- tickAndFlush(1);
- element._messageText = 'good news, everyone!';
- tickAndFlush(1);
- assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
- assert.isTrue(dispatchEventStub.calledTwice);
-
- element._messageText = 'good news, everyone!';
- await flush();
- assert.isTrue(dispatchEventStub.calledTwice);
-
- tap(queryAndAssert(element, '.save'));
-
- assert.isTrue(
- element.disabled,
- 'Element should be disabled when creating draft.'
- );
-
- let draft = await element._xhrPromise!;
- const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
- comment: DraftInfo;
- }>;
- assert.equal(evt.type, 'comment-save');
-
- const expectedDetail = {
- comment: {
- ...createComment(),
- __draft: true,
- __draftID: 'temp_draft_id',
- id: 'baf0414d_40572e03' as UrlEncodedCommentId,
- line: 5,
- message: 'saved!',
- path: '/path/to/file',
- updated: '2015-12-08 21:52:36.177000000' as Timestamp,
- },
- patchNum: 1 as PatchSetNum,
- };
-
- assert.deepEqual(evt.detail, expectedDetail);
- assert.isFalse(
- element.disabled,
- 'Element should be enabled when done creating draft.'
- );
- assert.equal(draft.message, 'saved!');
- assert.isFalse(element.editing);
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.calledTwice);
- element._messageText =
- 'You’ll be delivering a package to Chapek 9, ' +
- 'a world where humans are killed on sight.';
- tap(queryAndAssert(element, '.save'));
- assert.isTrue(
- element.disabled,
- 'Element should be disabled when updating draft.'
- );
- draft = await element._xhrPromise!;
- assert.isFalse(
- element.disabled,
- 'Element should be enabled when done updating draft.'
- );
- assert.equal(draft.message, 'saved!');
- assert.isFalse(element.editing);
- dispatchEventStub.restore();
- });
-
- test('draft prevent save when disabled', async () => {
- const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
- sinon.stub(element, '_fireEdit');
- element.showActions = true;
- element.draft = true;
- await flush();
- tap(element.$.header);
- tap(queryAndAssert(element, '.edit'));
- element._messageText = 'good news, everyone!';
- await flush();
-
- element.disabled = true;
- tap(queryAndAssert(element, '.save'));
- assert.isFalse(saveStub.called);
-
- element.disabled = false;
- tap(queryAndAssert(element, '.save'));
- assert.isTrue(saveStub.calledOnce);
- });
-
- test('proper event fires on resolve, comment is not saved', async () => {
- const save = sinon.stub(element, 'save');
- const promise = mockPromise();
- element.addEventListener('comment-update', e => {
- assert.isTrue(e.detail.comment.unresolved);
- assert.isFalse(save.called);
- promise.resolve();
- });
- tap(queryAndAssert(element, '.resolve input'));
- await promise;
- });
-
- test('resolved comment state indicated by checkbox', () => {
- sinon.stub(element, 'save');
- element.comment = {unresolved: false};
- assert.isTrue(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- element.comment = {unresolved: true};
- assert.isFalse(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- });
-
- test('resolved checkbox saves with tap when !editing', () => {
- element.editing = false;
- const save = sinon.stub(element, 'save');
-
- element.comment = {unresolved: false};
- assert.isTrue(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- element.comment = {unresolved: true};
- assert.isFalse(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- assert.isFalse(save.called);
- tap(element.$.resolvedCheckbox);
- assert.isTrue(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- assert.isTrue(save.called);
- });
-
- suite('draft saving messages', () => {
- test('_getSavingMessage', () => {
- assert.equal(element._getSavingMessage(0), 'All changes saved');
- assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
- assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
- assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
- });
-
- test('_show{Start,End}Request', () => {
- const updateStub = sinon.stub(element, '_updateRequestToast');
- element._numPendingDraftRequests.number = 1;
-
- element._showStartRequest();
- assert.isTrue(updateStub.calledOnce);
- assert.equal(updateStub.lastCall.args[0], 2);
- assert.equal(element._numPendingDraftRequests.number, 2);
-
- element._showEndRequest();
- assert.isTrue(updateStub.calledTwice);
- assert.equal(updateStub.lastCall.args[0], 1);
- assert.equal(element._numPendingDraftRequests.number, 1);
-
- element._showEndRequest();
- assert.isTrue(updateStub.calledThrice);
- assert.equal(updateStub.lastCall.args[0], 0);
- assert.equal(element._numPendingDraftRequests.number, 0);
- });
- });
-
- test('cancelling an unsaved draft discards, persists in storage', async () => {
- const clock: SinonFakeTimers = sinon.useFakeTimers();
- const tickAndFlush = async (repetitions: number) => {
- for (let i = 1; i <= repetitions; i++) {
- clock.tick(1000);
- await flush();
- }
- };
- const discardSpy = sinon.spy(element, '_fireDiscard');
- const storeStub = stubStorage('setDraftComment');
- const eraseStub = stubStorage('eraseDraftComment');
- element.comment!.id = undefined; // set id undefined for draft
- element._messageText = 'test text';
- tickAndFlush(1);
-
- assert.isTrue(storeStub.called);
- assert.equal(storeStub.lastCall.args[1], 'test text');
- element._handleCancel({
- ...new Event('click'),
- preventDefault: sinon.stub(),
- });
- await flush();
- assert.isTrue(discardSpy.called);
- assert.isFalse(eraseStub.called);
- });
-
- test('cancelling edit on a saved draft does not store', () => {
- element.comment!.id = 'foo' as UrlEncodedCommentId;
- const discardSpy = sinon.spy(element, '_fireDiscard');
- const storeStub = stubStorage('setDraftComment');
- element.comment!.id = undefined; // set id undefined for draft
- element._messageText = 'test text';
- flush();
-
- assert.isFalse(storeStub.called);
- element._handleCancel({...new Event('click'), preventDefault: () => {}});
- assert.isTrue(discardSpy.called);
- });
-
- test('deleting text from saved draft and saving deletes the draft', () => {
- element.comment = {
- ...createComment(),
- id: 'foo' as UrlEncodedCommentId,
- message: 'test',
- };
- element._messageText = '';
- const discardStub = sinon.stub(element, '_discardDraft');
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+ const textToSave = 'something, not important';
+ element.messageText = textToSave;
+ element.unresolved = true;
+ await element.updateComplete;
element.save();
- assert.isTrue(discardStub.called);
+
+ await element.updateComplete;
+ waitUntilCalled(stub, 'saveDraft()');
+ assert.equal(stub.lastCall.firstArg.message, textToSave);
+ assert.equal(stub.lastCall.firstArg.unresolved, true);
+ assert.isTrue(element.editing);
+ assert.isTrue(element.saving);
+
+ savePromise.resolve();
+ await element.updateComplete;
+
+ assert.isFalse(element.editing);
+ assert.isFalse(element.saving);
});
- test('_handleFix fires create-fix event', async () => {
- const promise = mockPromise();
- element.addEventListener(
- 'create-fix-comment',
- (e: CreateFixCommentEvent) => {
- assert.deepEqual(e.detail, element._getEventPayload());
- promise.resolve();
- }
+ test('save failed', async () => {
+ stubComments('saveDraft').returns(
+ Promise.reject(new Error('saving failed'))
);
- element.isRobotComment = true;
- element.comments = [element.comment!];
- await flush();
- tap(queryAndAssert(element, '.fix'));
- await promise;
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+ element.messageText = 'something, not important';
+ await element.updateComplete;
+
+ element.save();
+ await element.updateComplete;
+
+ assert.isTrue(element.unableToSave);
+ assert.isTrue(element.editing);
+ assert.isFalse(element.saving);
});
- test('do not show Please Fix button if human reply exists', () => {
- element.comments = [
- {
- robot_id: 'happy_robot_id' as RobotId,
- robot_run_id: '5838406743490560' as RobotRunId,
- fix_suggestions: [
- {
- fix_id: '478ff847_3bf47aaf' as FixId,
- description: 'Make the smiley happier by giving it a nose.',
- replacements: [
- {
- path: 'Documentation/config-gerrit.txt',
- range: {
- start_line: 10,
- start_character: 7,
- end_line: 10,
- end_character: 9,
- },
- replacement: ':-)',
- },
- ],
- },
- ],
- author: {
- _account_id: 1030912 as AccountId,
- name: 'Alice Kober-Sotzek',
- email: 'aliceks@google.com' as EmailAddress,
- avatars: [
- {
- url: '/s32-p/photo.jpg',
- height: 32,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s56-p/photo.jpg',
- height: 56,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s100-p/photo.jpg',
- height: 100,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s120-p/photo.jpg',
- height: 120,
- width: 32,
- },
- ],
- },
- patch_set: 1 as PatchSetNum,
- ...createComment(),
- id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
- line: 10,
- updated: '2017-04-04 15:36:17.000000000' as Timestamp,
- message: 'This is a robot comment with a fix.',
- unresolved: false,
- collapsed: false,
- },
- {
- __draft: true,
- __draftID: '0.wbrfbwj89sa',
- __date: new Date(),
- path: 'Documentation/config-gerrit.txt',
- side: CommentSide.REVISION,
- line: 10,
- in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
- message: '> This is a robot comment with a fix.\n\nPlease fix.',
- unresolved: true,
- },
- ];
- element.comment = element.comments[0];
- flush();
- assert.isNull(
- element.shadowRoot?.querySelector('robotActions gr-button')
- );
+ test('discard', async () => {
+ const discardPromise = mockPromise<void>();
+ const stub = stubComments('discardDraft').returns(discardPromise);
+
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+
+ element.discard();
+
+ await element.updateComplete;
+ waitUntilCalled(stub, 'discardDraft()');
+ assert.equal(stub.lastCall.firstArg, element.comment.id);
+ assert.isTrue(element.editing);
+ assert.isTrue(element.saving);
+
+ discardPromise.resolve();
+ await element.updateComplete;
+
+ assert.isFalse(element.editing);
+ assert.isFalse(element.saving);
});
- test('show Please Fix if no human reply', () => {
- element.comments = [
- {
- robot_id: 'happy_robot_id' as RobotId,
- robot_run_id: '5838406743490560' as RobotRunId,
- fix_suggestions: [
- {
- fix_id: '478ff847_3bf47aaf' as FixId,
- description: 'Make the smiley happier by giving it a nose.',
- replacements: [
- {
- path: 'Documentation/config-gerrit.txt',
- range: {
- start_line: 10,
- start_character: 7,
- end_line: 10,
- end_character: 9,
- },
- replacement: ':-)',
- },
- ],
- },
- ],
- author: {
- _account_id: 1030912 as AccountId,
- name: 'Alice Kober-Sotzek',
- email: 'aliceks@google.com' as EmailAddress,
- avatars: [
- {
- url: '/s32-p/photo.jpg',
- height: 32,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s56-p/photo.jpg',
- height: 56,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s100-p/photo.jpg',
- height: 100,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s120-p/photo.jpg',
- height: 120,
- width: 32,
- },
- ],
- },
- patch_set: 1 as PatchSetNum,
- ...createComment(),
- id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
- line: 10,
- updated: '2017-04-04 15:36:17.000000000' as Timestamp,
- message: 'This is a robot comment with a fix.',
- unresolved: false,
- collapsed: false,
- },
- ];
- element.comment = element.comments[0];
- flush();
- queryAndAssert(element, '.robotActions gr-button');
- });
-
- test('_handleShowFix fires open-fix-preview event', async () => {
- const promise = mockPromise();
- element.addEventListener('open-fix-preview', e => {
- assert.deepEqual(e.detail, element._getEventPayload());
- promise.resolve();
- });
+ test('resolved comment state indicated by checkbox', async () => {
+ const saveStub = sinon.stub(element, 'save');
element.comment = {
...createComment(),
+ __draft: true,
+ unresolved: false,
+ };
+ await element.updateComplete;
+
+ let checkbox = queryAndAssert<HTMLInputElement>(
+ element,
+ '#resolvedCheckbox'
+ );
+ assert.isTrue(checkbox.checked);
+
+ tap(checkbox);
+ await element.updateComplete;
+
+ checkbox = queryAndAssert<HTMLInputElement>(element, '#resolvedCheckbox');
+ assert.isFalse(checkbox.checked);
+
+ assert.isTrue(saveStub.called);
+ });
+
+ test('saving empty text calls discard()', async () => {
+ const saveStub = stubComments('saveDraft');
+ const discardStub = stubComments('discardDraft');
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+
+ element.messageText = '';
+ await element.updateComplete;
+
+ await element.save();
+ assert.isTrue(discardStub.called);
+ assert.isFalse(saveStub.called);
+ });
+
+ test('handleFix fires create-fix event', async () => {
+ const listener = listenOnce<CreateFixCommentEvent>(
+ element,
+ 'create-fix-comment'
+ );
+ element.comment = createRobotComment();
+ element.comments = [element.comment!];
+ await element.updateComplete;
+
+ tap(queryAndAssert(element, '.fix'));
+
+ const e = await listener;
+ assert.deepEqual(e.detail, element.getEventPayload());
+ });
+
+ test('do not show Please Fix button if human reply exists', async () => {
+ element.initiallyCollapsed = false;
+ const robotComment = createRobotComment();
+ element.comment = robotComment;
+ await element.updateComplete;
+
+ let actions = query(element, '.robotActions gr-button.fix');
+ assert.isOk(actions);
+
+ element.comments = [
+ robotComment,
+ {...createComment(), in_reply_to: robotComment.id},
+ ];
+ await element.updateComplete;
+ actions = query(element, '.robotActions gr-button.fix');
+ assert.isNotOk(actions);
+ });
+
+ test('handleShowFix fires open-fix-preview event', async () => {
+ const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
+ element,
+ 'open-fix-preview'
+ );
+ element.comment = {
+ ...createRobotComment(),
fix_suggestions: [{...createFixSuggestionInfo()}],
};
- element.isRobotComment = true;
- await flush();
+ await element.updateComplete;
tap(queryAndAssert(element, '.show-fix'));
- await promise;
+
+ const e = await listener;
+ assert.deepEqual(e.detail, element.getEventPayload());
+ });
+ });
+
+ suite('auto saving', () => {
+ let clock: sinon.SinonFakeTimers;
+ let savePromise: MockPromise<void>;
+ let saveStub: SinonStub;
+
+ setup(async () => {
+ clock = sinon.useFakeTimers();
+ savePromise = mockPromise<void>();
+ saveStub = stubComments('saveDraft').returns(savePromise);
+
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+ });
+
+ teardown(() => {
+ clock.restore();
+ sinon.restore();
+ });
+
+ test('basic auto saving', async () => {
+ const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+ dispatch(textarea, 'text-changed', {value: 'some new text '});
+
+ clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS / 2);
+ assert.isFalse(saveStub.called);
+
+ clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS);
+ assert.isTrue(saveStub.called);
+ assert.equal(
+ saveStub.firstCall.firstArg.message,
+ 'some new text '.trimEnd()
+ );
+ });
+
+ test('saving while auto saving', async () => {
+ const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+ dispatch(textarea, 'text-changed', {value: 'auto save text'});
+
+ clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
+ assert.isTrue(saveStub.called);
+ assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
+ saveStub.reset();
+
+ element.messageText = 'actual save text';
+ element.save();
+ await element.updateComplete;
+ // First wait for the auto saving to finish.
+ assert.isFalse(saveStub.called);
+
+ savePromise.resolve();
+ await element.updateComplete;
+ // Only then save.
+ assert.isTrue(saveStub.called);
+ assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
});
});
suite('respectful tips', () => {
- let element: GrComment;
-
let clock: sinon.SinonFakeTimers;
- setup(() => {
- stubRestApi('getAccount').returns(Promise.resolve(undefined));
+ setup(async () => {
clock = sinon.useFakeTimers();
});
@@ -1648,81 +680,81 @@
});
test('show tip when no cached record', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 0;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isTrue(respectfulSetStub.called);
- assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+ queryAndAssert(element, '.respectfulReviewTip');
});
test('add 14-day delays once dismissed', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 0;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isTrue(respectfulSetStub.called);
assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
- assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+ const closeLink = queryAndAssert(element, '.respectfulReviewTip a.close');
+ tap(closeLink);
+ await element.updateComplete;
- tap(queryAndAssert(element, '.respectfulReviewTip .close'));
- flush();
assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
});
test('do not show tip when fall out of probability', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 3;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 2;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isFalse(respectfulSetStub.called);
assert.isNotOk(query(element, '.respectfulReviewTip'));
});
test('show tip when editing changed to true', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: false};
- await flush();
+ element.editing = false;
+ element.getRandomInt = () => 0;
+ element.comment = createComment();
+ await element.updateComplete;
+
assert.isFalse(respectfulGetStub.called);
assert.isFalse(respectfulSetStub.called);
assert.isNotOk(query(element, '.respectfulReviewTip'));
element.editing = true;
- await flush();
+ await element.updateComplete;
assert.isTrue(respectfulGetStub.called);
assert.isTrue(respectfulSetStub.called);
assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
});
test('no tip when cached record', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns({updated: 0});
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 0;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isFalse(respectfulSetStub.called);
assert.isNotOk(query(element, '.respectfulReviewTip'));
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 4657020..23d9693 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -25,7 +25,6 @@
import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
import {GrSelect} from '../gr-select/gr-select';
import {getAppContext} from '../../../services/app-context';
-import {diffPreferences$} from '../../../services/user/user-model';
export interface GrDiffPreferences {
$: {
@@ -56,14 +55,14 @@
@property({type: Object})
diffPrefs?: DiffPreferencesInfo;
- private readonly userService = getAppContext().userService;
+ private readonly userModel = getAppContext().userModel;
private subscriptions: Subscription[] = [];
override connectedCallback() {
super.connectedCallback();
this.subscriptions.push(
- diffPreferences$.subscribe(diffPreferences => {
+ this.userModel.diffPreferences$.subscribe(diffPreferences => {
this.diffPrefs = diffPreferences;
})
);
@@ -142,7 +141,7 @@
async save() {
if (!this.diffPrefs) return;
- await this.userService.updateDiffPreference(this.diffPrefs);
+ await this.userModel.updateDiffPreference(this.diffPrefs);
this.hasUnsavedChanges = false;
}
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
index 41ac3e3..8abd679 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -55,6 +55,109 @@
});
test('renders', () => {
+ expect(element).shadowDom.to.equal(`<div
+ class="gr-form-styles"
+ id="diffPreferences"
+ >
+ <section>
+ <label class="title" for="contextLineSelect">Context</label>
+ <span class="value">
+ <gr-select id="contextSelect">
+ <select id="contextLineSelect">
+ <option value="3">3 lines</option>
+ <option value="10">10 lines</option>
+ <option value="25">25 lines</option>
+ <option value="50">50 lines</option>
+ <option value="75">75 lines</option>
+ <option value="100">100 lines</option>
+ <option value="-1">Whole file</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="lineWrappingInput">Fit to screen</label>
+ <span class="value">
+ <input id="lineWrappingInput" type="checkbox">
+ </span>
+ </section>
+ <section>
+ <label class="title" for="columnsInput">Diff width</label>
+ <span class="value">
+ <iron-input allowed-pattern="[0-9]">
+ <input id="columnsInput" type="number">
+ </iron-input>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="tabSizeInput">Tab width</label>
+ <span class="value">
+ <iron-input allowed-pattern="[0-9]">
+ <input id="tabSizeInput" type="number">
+ </iron-input>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="fontSizeInput">Font size</label>
+ <span class="value">
+ <iron-input allowed-pattern="[0-9]">
+ <input id="fontSizeInput" type="number">
+ </iron-input>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="showTabsInput">Show tabs</label>
+ <span class="value">
+ <input id="showTabsInput" type="checkbox">
+ </span>
+ </section>
+ <section>
+ <label class="title" for="showTrailingWhitespaceInput">
+ Show trailing whitespace
+ </label>
+ <span class="value">
+ <input id="showTrailingWhitespaceInput" type="checkbox">
+ </span>
+ </section>
+ <section>
+ <label class="title" for="syntaxHighlightInput">
+ Syntax highlighting
+ </label>
+ <span class="value">
+ <input id="syntaxHighlightInput" type="checkbox">
+ </span>
+ </section>
+ <section>
+ <label class="title" for="automaticReviewInput">
+ Automatically mark viewed files reviewed
+ </label>
+ <span class="value">
+ <input id="automaticReviewInput" type="checkbox">
+ </span>
+ </section>
+ <section>
+ <div class="pref">
+ <label class="title" for="ignoreWhiteSpace">
+ Ignore Whitespace
+ </label>
+ <span class="value">
+ <gr-select>
+ <select id="ignoreWhiteSpace">
+ <option value="IGNORE_NONE">None</option>
+ <option value="IGNORE_TRAILING">Trailing</option>
+ <option value="IGNORE_LEADING_AND_TRAILING">
+ Leading & trailing
+ </option>
+ <option value="IGNORE_ALL">All</option>
+ </select>
+ </gr-select>
+ </span>
+ </div>
+ </section>
+ </div>`);
+ });
+
+ test('renders preferences', () => {
// Rendered with the expected preferences selected.
const contextInput = valueOf('Context', 'diffPreferences')
.firstElementChild as IronInputElement;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 68d8d7d..6eb19da 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -27,7 +27,6 @@
import {getAppContext} from '../../../services/app-context';
import {queryAndAssert} from '../../../utils/common-util';
import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
-import {preferences$} from '../../../services/user/user-model';
declare global {
interface HTMLElementEventMap {
@@ -73,7 +72,8 @@
private readonly restApiService = getAppContext().restApiService;
- private readonly userService = getAppContext().userService;
+ // Private but used in tests.
+ readonly userModel = getAppContext().userModel;
private subscriptions: Subscription[] = [];
@@ -83,7 +83,7 @@
this._loggedIn = loggedIn;
});
this.subscriptions.push(
- preferences$.subscribe(prefs => {
+ this.userModel.preferences$.subscribe(prefs => {
if (prefs?.download_scheme) {
// Note (issue 5180): normalize the download scheme with lower-case.
this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -113,7 +113,7 @@
if (scheme && scheme !== this.selectedScheme) {
this.set('selectedScheme', scheme);
if (this._loggedIn) {
- this.userService.updatePreferences({
+ this.userModel.updatePreferences({
download_scheme: this.selectedScheme,
});
}
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 6cbef79..bd0ca70 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -19,7 +19,6 @@
import './gr-download-commands';
import {GrDownloadCommands} from './gr-download-commands';
import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
-import {updatePreferences} from '../../../services/user/user-model';
import {createPreferences} from '../../../test/test-data-generators';
import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
@@ -116,7 +115,7 @@
test('loads scheme from preferences', async () => {
const element = basicFixture.instantiate();
await flush();
- updatePreferences({
+ element.userModel.setPreferences({
...createPreferences(),
download_scheme: 'repo',
});
@@ -126,7 +125,7 @@
test('normalize scheme from preferences', async () => {
const element = basicFixture.instantiate();
await flush();
- updatePreferences({
+ element.userModel.setPreferences({
...createPreferences(),
download_scheme: 'REPO',
});
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 6180f35..a39d033 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -78,7 +78,7 @@
@property({type: Number})
initialCount = 75;
- @property({type: Object})
+ @property({type: Array})
items?: DropdownItem[];
@property({type: String})
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 2b56de6..4045b6d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -133,19 +133,19 @@
override connectedCallback() {
super.connectedCallback();
this.cleanups.push(
- addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+ addShortcut(this, {key: Key.UP}, () => this._handleUp())
);
this.cleanups.push(
- addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+ addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
);
this.cleanups.push(
- addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+ addShortcut(this, {key: Key.TAB}, () => this._handleTab())
);
this.cleanups.push(
- addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+ addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
);
this.cleanups.push(
- addShortcut(this, {key: Key.SPACE}, e => this._handleEnter(e))
+ addShortcut(this, {key: Key.SPACE}, () => this._handleEnter())
);
}
@@ -159,10 +159,8 @@
/**
* Handle the up key.
*/
- _handleUp(e: Event) {
+ _handleUp() {
if (this.$.dropdown.opened) {
- e.preventDefault();
- e.stopPropagation();
this.cursor.previous();
} else {
this._open();
@@ -172,10 +170,8 @@
/**
* Handle the down key.
*/
- _handleDown(e: Event) {
+ _handleDown() {
if (this.$.dropdown.opened) {
- e.preventDefault();
- e.stopPropagation();
this.cursor.next();
} else {
this._open();
@@ -185,20 +181,14 @@
/**
* Handle the tab key.
*/
- _handleTab(e: Event) {
- if (this.$.dropdown.opened) {
- // Tab in a native select is a no-op. Emulate this.
- e.preventDefault();
- e.stopPropagation();
- }
+ _handleTab() {
+ // Tab in a native select is a no-op. Emulate this.
}
/**
* Handle the enter key.
*/
- _handleEnter(e: Event) {
- e.preventDefault();
- e.stopPropagation();
+ _handleEnter() {
if (this.$.dropdown.opened) {
// TODO(milutin): This solution is not particularly robust in general.
// Since gr-tooltip-content click on shadow dom is not propagated down,
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index e0d1d15..7e6c17c 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -218,7 +218,6 @@
.composedPath()
.some(element => element === inputContainer);
if (isEventFromInput) {
- event.preventDefault();
this._save();
}
}
@@ -229,7 +228,6 @@
.composedPath()
.some(element => element === inputContainer);
if (isEventFromInput) {
- event.preventDefault();
this._cancel();
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index 996edf3..1088b27 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -17,9 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../change/gr-change-actions/gr-change-actions.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-annotation-actions-js-api tests', () => {
let annotationActions;
@@ -27,7 +24,7 @@
let plugin;
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
annotationActions = plugin.annotationApi();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
index 87f6052..b70c8ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -19,12 +19,8 @@
import '../../change/gr-change-actions/gr-change-actions.js';
import {resetPlugins} from '../../../test/test-utils.js';
import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
const basicFixture = fixtureFromElement('gr-change-actions');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-change-actions-js-api-interface tests', () => {
let element;
let changeActions;
@@ -41,7 +37,7 @@
suite('early init', () => {
setup(() => {
resetPlugins();
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
// Mimic all plugins loaded.
getPluginLoader().loadPlugins([]);
@@ -68,7 +64,7 @@
sinon.stub(element, '_editStatusChanged');
element.change = {};
element._hasKnownChainState = false;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
changeActions = plugin.changeActions();
// Mimic all plugins loaded.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
index 2324588..52d6ab3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -17,16 +17,12 @@
import '../../../test/common-test-setup-karma.js';
import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {stubRestApi} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-reply-dialog');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-change-reply-js-api tests', () => {
let element;
-
let changeReply;
let plugin;
@@ -36,7 +32,7 @@
suite('early init', () => {
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
changeReply = plugin.changeReply();
element = basicFixture.instantiate();
@@ -64,7 +60,7 @@
suite('normal init', () => {
setup(() => {
element = basicFixture.instantiate();
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
changeReply = plugin.changeReply();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index d76b2b7..07fad80 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -81,12 +81,12 @@
Auth: AuthService;
}
-export function initGerritPluginApi() {
- window.Gerrit = window.Gerrit ?? new GerritImpl(getAppContext());
+export function initGerritPluginApi(appContext: AppContext) {
+ window.Gerrit = window.Gerrit ?? new GerritImpl(appContext);
}
-export function _testOnly_initGerritPluginApi(): GerritInternal {
- initGerritPluginApi();
+export function _testOnly_getGerritInternalPluginApi(): GerritInternal {
+ if (!window.Gerrit) throw new Error('initGerritPluginApi was not called');
return window.Gerrit as GerritInternal;
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
index ae0c370..d53c266 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -18,7 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import {getPluginLoader} from './gr-plugin-loader.js';
import {resetPlugins} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {_testOnly_getGerritInternalPluginApi} from './gr-gerrit.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {getAppContext} from '../../../services/app-context.js';
@@ -33,7 +33,7 @@
stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
stubRestApi('send').returns(Promise.resolve({status: 200}));
element = getAppContext().jsApiService;
- pluginApi = _testOnly_initGerritPluginApi();
+ pluginApi = _testOnly_getGerritInternalPluginApi();
});
teardown(() => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index a48f91c..c45bbf5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -21,13 +21,10 @@
import {EventType} from '../../../api/plugin.js';
import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {getAppContext} from '../../../services/app-context.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('GrJsApiInterface tests', () => {
let element;
let plugin;
@@ -47,7 +44,7 @@
sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
element = getAppContext().jsApiService;
errorStub = sinon.stub(element.reporting, 'error');
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
getPluginLoader().loadPlugins([]);
});
@@ -300,7 +297,7 @@
setup(() => {
stubBaseUrl('/r');
- pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
+ window.Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
'http://test.com/r/plugins/baseurlplugin/static/test.js');
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index 34c976a..d4b93a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -18,18 +18,15 @@
import '../../../test/common-test-setup-karma.js';
import './gr-js-api-interface.js';
import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {addListenerForTest} from '../../../test/test-utils.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-plugin-action-context tests', () => {
let instance;
let plugin;
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
instance = new GrPluginActionContext(plugin);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index c7bdfb4..16846f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -18,12 +18,9 @@
import {resetPlugins} from '../../../test/test-utils';
import './gr-js-api-interface';
import {GrPluginEndpoints} from './gr-plugin-endpoints';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit';
import {PluginApi} from '../../../api/plugin';
import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
-const pluginApi = _testOnly_initGerritPluginApi();
-
export class MockHook<T extends PluginElement> implements HookApi<T> {
handleInstanceDetached(_: T) {}
@@ -59,7 +56,7 @@
setup(() => {
domHook = new MockHook<PluginElement>();
instance = new GrPluginEndpoints();
- pluginApi.install(
+ window.Gerrit.install(
plugin => (decoratePlugin = plugin),
'0.1',
'http://test.com/plugins/testplugin/static/decorate.js'
@@ -70,7 +67,7 @@
moduleName: 'decorate-module',
domHook,
});
- pluginApi.install(
+ window.Gerrit.install(
plugin => (stylePlugin = plugin),
'0.1',
'http://test.com/plugins/testplugin/static/style.js'
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 16656d2..ba6de67 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -22,6 +22,7 @@
import {PluginApi} from '../../../api/plugin';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {ShowAlertEventDetail} from '../../../types/events';
+import {fireAlert} from '../../../utils/event-util';
enum PluginState {
/** State that indicates the plugin is pending to be loaded. */
@@ -209,9 +210,11 @@
this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
}
this._checkIfCompleted();
- return `Timeout when loading plugins: ${pending
+ const errorMessage = `Timeout when loading plugins: ${pending
.map(p => p.name)
.join(',')}`;
+ fireAlert(document, errorMessage);
+ return errorMessage;
}
_failToLoad(message: string, pluginUrl?: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index ab69267..e097858 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -19,11 +19,8 @@
import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-plugin-loader tests', () => {
let plugin;
@@ -47,18 +44,18 @@
});
test('reuse plugin for install calls', () => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
let otherPlugin;
- pluginApi.install(p => { otherPlugin = p; }, '0.1',
+ window.Gerrit.install(p => { otherPlugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
assert.strictEqual(plugin, otherPlugin);
});
test('versioning', () => {
const callback = sinon.spy();
- pluginApi.install(callback, '0.0pre-alpha');
+ window.Gerrit.install(callback, '0.0pre-alpha');
assert(callback.notCalled);
});
@@ -89,7 +86,7 @@
test('plugins installed successfully', async () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => void 0, undefined, url);
+ window.Gerrit.install(() => void 0, undefined, url);
});
const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
'pluginsLoaded');
@@ -107,7 +104,7 @@
test('isPluginEnabled and isPluginLoaded', () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => void 0, undefined, url);
+ window.Gerrit.install(() => void 0, undefined, url);
});
const plugins = [
@@ -137,7 +134,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
if (url === plugins[0]) {
throw new Error('failed');
}
@@ -165,7 +162,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
if (url === plugins[0]) {
throw new Error('failed');
}
@@ -198,7 +195,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
throw new Error('failed');
}, undefined, url);
});
@@ -224,7 +221,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
}, url === plugins[0] ? '' : 'alpha', url);
});
@@ -241,7 +238,7 @@
test('multiple assets for same plugin installed successfully', async () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => void 0, undefined, url);
+ window.Gerrit.install(() => void 0, undefined, url);
});
const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
'pluginsLoaded');
@@ -388,7 +385,7 @@
}
}
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => pluginCallback(url), undefined, url);
+ window.Gerrit.install(() => pluginCallback(url), undefined, url);
});
pluginLoader.loadPlugins(plugins);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
index d2b5658..730f163 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -18,11 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-js-api-interface.js';
import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {stubRestApi} from '../../../test/test-utils.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-plugin-rest-api tests', () => {
let instance;
let getResponseObjectStub;
@@ -33,7 +30,7 @@
getResponseObjectStub = stubRestApi('getResponseObject').returns(
Promise.resolve());
sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
- pluginApi.install(p => {}, '0.1',
+ window.Gerrit.install(p => {}, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
instance = new GrPluginRestApi();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
index a0f2e02..c96a075 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {GerritInternal, _testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {getAppContext} from '../../../services/app-context.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {PluginApi} from '../../../api/plugin.js';
@@ -27,10 +26,8 @@
suite('gr-reporting-js-api tests', () => {
let plugin: PluginApi;
let reportingService: ReportingService;
- let pluginApi: GerritInternal;
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
stubRestApi('getAccount').returns(Promise.resolve(undefined));
reportingService = getAppContext().reportingService;
});
@@ -38,7 +35,7 @@
suite('early init', () => {
let reporting: ReportingPluginApi;
setup(() => {
- pluginApi.install(
+ window.Gerrit.install(
p => {
plugin = p;
},
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 888f477..70de130 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -44,6 +44,7 @@
getVotingRangeOrDefault,
hasNeutralStatus,
hasVoted,
+ showNewSubmitRequirements,
valueString,
} from '../../../utils/label-util';
import {getAppContext} from '../../../services/app-context';
@@ -53,7 +54,6 @@
import {votingStyles} from '../../../styles/gr-voting-styles';
import {ifDefined} from 'lit/directives/if-defined';
import {fireReload} from '../../../utils/event-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {sortReviewers} from '../../../utils/attention-set-util';
declare global {
@@ -104,10 +104,6 @@
@property({type: Boolean})
showAllReviewers = true;
- /** temporary until submit requirements are finished */
- @property({type: Boolean})
- showAlwaysOldUI = false;
-
private readonly restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
@@ -214,10 +210,7 @@
}
override render() {
- if (
- this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
- !this.showAlwaysOldUI
- ) {
+ if (showNewSubmitRequirements(this.flagsService, this.change)) {
return this.renderNewSubmitRequirements();
} else {
return this.renderOldSubmitRequirements();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
similarity index 97%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
index a3dc479..37b14b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
@@ -156,6 +156,7 @@
import {ParsedChangeInfo} from '../../../types/types';
import {ErrorCallback} from '../../../api/rest';
import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
+import {addDraftProp, DraftInfo} from '../../../utils/comment-util';
const MAX_PROJECT_RESULTS = 25;
@@ -269,12 +270,12 @@
declare global {
interface HTMLElementTagNameMap {
- 'gr-rest-api-interface': GrRestApiInterface;
+ 'gr-rest-api-service-impl': GrRestApiServiceImpl;
}
}
-@customElement('gr-rest-api-interface')
-export class GrRestApiInterface
+@customElement('gr-rest-api-service-impl')
+export class GrRestApiServiceImpl
extends PolymerElement
implements RestApiService, Finalizable
{
@@ -1180,20 +1181,6 @@
return listChangesOptionsToHex(...options);
}
- getDiffChangeDetail(changeNum: NumericChangeId) {
- let optionsHex = '';
- if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
- optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
- } else {
- optionsHex = listChangesOptionsToHex(
- ListChangesOption.ALL_COMMITS,
- ListChangesOption.ALL_REVISIONS,
- ListChangesOption.SKIP_DIFFSTAT
- );
- }
- return this._getChangeDetail(changeNum, optionsHex);
- }
-
/**
* @param optionsHex list changes options in hex
*/
@@ -1703,11 +1690,12 @@
}
getChangesSubmittedTogether(
- changeNum: NumericChangeId
+ changeNum: NumericChangeId,
+ options: string[] = ['NON_VISIBLE_CHANGES']
): Promise<SubmittedTogetherInfo | undefined> {
return this._getChangeURLAndFetch({
changeNum,
- endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+ endpoint: `/submitted_together?o=${options.join('&o=')}`,
reportEndpointAsIs: true,
}) as Promise<SubmittedTogetherInfo | undefined>;
}
@@ -1758,22 +1746,27 @@
getChangesWithSameTopic(
topic: string,
- changeNum: NumericChangeId
+ options?: {
+ openChangesOnly?: boolean;
+ changeToExclude?: NumericChangeId;
+ }
): Promise<ChangeInfo[] | undefined> {
- const options = listChangesOptionsToHex(
+ const requestOptions = listChangesOptionsToHex(
ListChangesOption.LABELS,
ListChangesOption.CURRENT_REVISION,
ListChangesOption.CURRENT_COMMIT,
ListChangesOption.DETAILED_LABELS
);
- const query = [
- 'status:open',
- `-change:${changeNum}`,
- `topic:"${topic}"`,
- ].join(' ');
+ const queryTerms = [`topic:"${topic}"`];
+ if (options?.openChangesOnly) {
+ queryTerms.push('status:open');
+ }
+ if (options?.changeToExclude !== undefined) {
+ queryTerms.push(`-change:${options.changeToExclude}`);
+ }
const params = {
- O: options,
- q: query,
+ O: requestOptions,
+ q: queryTerms.join(' '),
};
return this._restApiHelper.fetchJSON({
url: '/changes/',
@@ -2284,45 +2277,16 @@
* is no logged in user, the request is not made and the promise yields an
* empty object.
*/
- getDiffDrafts(
+ async getDiffDrafts(
changeNum: NumericChangeId
- ): Promise<PathToCommentsInfoMap | undefined>;
-
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum: BasePatchSetNum,
- patchNum: PatchSetNum,
- path: string
- ): Promise<GetDiffCommentsOutput>;
-
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum?: BasePatchSetNum,
- patchNum?: PatchSetNum,
- path?: string
- ) {
- return this.getLoggedIn().then(loggedIn => {
- if (!loggedIn) {
- return {};
- }
- if (!basePatchNum && !patchNum && !path) {
- return this._getDiffComments(changeNum, '/drafts', {
- 'enable-context': true,
- 'context-padding': 3,
- });
- }
- return this._getDiffComments(
- changeNum,
- '/drafts',
- {
- 'enable-context': true,
- 'context-padding': 3,
- },
- basePatchNum,
- patchNum,
- path
- );
+ ): Promise<{[path: string]: DraftInfo[]} | undefined> {
+ const loggedIn = await this.getLoggedIn();
+ if (!loggedIn) return {};
+ const comments = await this._getDiffComments(changeNum, '/drafts', {
+ 'enable-context': true,
+ 'context-padding': 3,
});
+ return addDraftProp(comments);
}
_setRange(comments: CommentInfo[], comment: CommentInfo) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
similarity index 99%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
index 0d3978a..b3f751a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
@@ -27,9 +27,9 @@
readResponsePayload,
} from './gr-rest-apis/gr-rest-api-helper.js';
import {JSON_PREFIX} from './gr-rest-apis/gr-rest-api-helper.js';
-import {GrRestApiInterface} from './gr-rest-api-interface.js';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl.js';
-suite('gr-rest-api-interface tests', () => {
+suite('gr-rest-api-service-impl tests', () => {
let element;
let ctr = 0;
@@ -51,7 +51,10 @@
// fake auth
sinon.stub(getAppContext().authService, 'authCheck')
.returns(Promise.resolve(true));
- element = new GrRestApiInterface();
+ element = new GrRestApiServiceImpl(
+ getAppContext().authService,
+ getAppContext().flagsService
+ );
element._projectLookup = {};
});
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 8d833ca..b602a87 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -153,19 +153,29 @@
override connectedCallback() {
super.connectedCallback();
this.cleanups.push(
- addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
+ addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
+ addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
+ addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
+ addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e), {
+ doNotPrevent: true,
+ })
);
this.cleanups.push(
- addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
+ addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e), {
+ doNotPrevent: true,
+ })
);
}
@@ -414,6 +424,9 @@
}
_handleTextChanged(text: string) {
+ // This is a bit redundant, because the `text` property has `notify:true`,
+ // so whenever the `text` changes the component fires two identical events
+ // `text-changed` and `value-changed`.
this.dispatchEvent(
new CustomEvent('value-changed', {detail: {value: text}})
);
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-project.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo.ts
similarity index 69%
rename from polygerrit-ui/app/elements/topic/gr-topic-tree-project.ts
rename to polygerrit-ui/app/elements/topic/gr-topic-tree-repo.ts
index c3fed91..234f058 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-project.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo.ts
@@ -17,28 +17,34 @@
import './gr-topic-tree-row';
import {customElement, property} from 'lit/decorators';
-import {LitElement, html} from 'lit-element/lit-element';
+import {LitElement, html, css} from 'lit-element/lit-element';
import '../shared/gr-button/gr-button';
-import {ChangeInfo} from '../../api/rest-api';
+import {ChangeInfo, RepoName} from '../../api/rest-api';
/**
- * A view of changes that all belong to the same project.
+ * A view of changes that all belong to the same repository.
*/
-@customElement('gr-topic-tree-project')
-export class GrTopicTreeProject extends LitElement {
+@customElement('gr-topic-tree-repo')
+export class GrTopicTreeRepo extends LitElement {
@property({type: String})
- projectName?: string;
+ repoName?: RepoName;
@property({type: Array})
changes?: ChangeInfo[];
+ static override styles = css`
+ :host {
+ display: contents;
+ }
+ `;
+
override render() {
- if (this.projectName === undefined || this.changes === undefined) {
+ if (this.repoName === undefined || this.changes === undefined) {
return;
}
- // TODO: Groups of related changes should be separated within the project.
+ // TODO: Groups of related changes should be separated within the repository.
return html`
- <h2>Project ${this.projectName}</h2>
+ <h2>Repo ${this.repoName}</h2>
${this.changes.map(change => this.renderTreeRow(change))}
`;
}
@@ -50,6 +56,6 @@
declare global {
interface HTMLElementTagNameMap {
- 'gr-topic-tree-project': GrTopicTreeProject;
+ 'gr-topic-tree-repo': GrTopicTreeRepo;
}
}
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-project_test.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo_test.ts
similarity index 69%
rename from polygerrit-ui/app/elements/topic/gr-topic-tree-project_test.ts
rename to polygerrit-ui/app/elements/topic/gr-topic-tree-repo_test.ts
index 39398bd..2e903b5 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-project_test.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-repo_test.ts
@@ -15,27 +15,28 @@
* limitations under the License.
*/
+import {RepoName} from '../../api/rest-api';
import '../../test/common-test-setup-karma';
import {createChange} from '../../test/test-data-generators';
import {queryAndAssert} from '../../test/test-utils';
-import './gr-topic-tree-project';
-import {GrTopicTreeProject} from './gr-topic-tree-project';
+import './gr-topic-tree-repo';
+import {GrTopicTreeRepo} from './gr-topic-tree-repo';
-const basicFixture = fixtureFromElement('gr-topic-tree-project');
-const projectName = 'myProject';
+const basicFixture = fixtureFromElement('gr-topic-tree-repo');
+const repoName = 'myRepo' as RepoName;
-suite('gr-topic-tree-project tests', () => {
- let element: GrTopicTreeProject;
+suite('gr-topic-tree-repo tests', () => {
+ let element: GrTopicTreeRepo;
setup(async () => {
element = basicFixture.instantiate();
- element.projectName = projectName;
+ element.repoName = repoName;
element.changes = [createChange()];
await element.updateComplete;
});
- test('shows project name', () => {
+ test('shows repository name', () => {
const heading = queryAndAssert<HTMLHeadingElement>(element, 'h2');
- assert.equal(heading.textContent, `Project ${projectName}`);
+ assert.equal(heading.textContent, `Repo ${repoName}`);
});
});
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts
index ac6031d..0355bee 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-row.ts
@@ -16,7 +16,7 @@
*/
import {customElement, property} from 'lit/decorators';
-import {LitElement, html} from 'lit-element/lit-element';
+import {LitElement, html, css} from 'lit-element/lit-element';
import '../shared/gr-button/gr-button';
import {ChangeInfo} from '../../api/rest-api';
@@ -36,17 +36,28 @@
@property({type: Object})
change?: ChangeInfo;
+ static override styles = css`
+ :host {
+ display: contents;
+ }
+ `;
+
override render() {
if (this.change === undefined) {
return;
}
+ const authorName =
+ this.change.revisions?.[this.change.current_revision!].commit?.author
+ .name;
return html`
- <span>${this.computeSize(this.change)}</span>
- <span>${this.change.subject}</span>
- <span>${this.change.topic}</span>
- <span>${this.change.branch}</span>
- <span>${this.change.owner.name}</span>
- <span>${this.change.status}</span>
+ <tr>
+ <td>${this.computeSize(this.change)}</td>
+ <td>${this.change.subject}</td>
+ <td>${this.change.topic}</td>
+ <td>${this.change.branch}</td>
+ <td>${authorName}</td>
+ <td>${this.change.status}</td>
+ </tr>
`;
}
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts
index a664845..e73cf13 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree-row_test.ts
@@ -18,8 +18,7 @@
import {ChangeInfo, ChangeStatus, TopicName} from '../../api/rest-api';
import '../../test/common-test-setup-karma';
import {
- createAccountWithIdNameAndEmail,
- createChange,
+ createChangeViewChange,
TEST_BRANCH_ID,
TEST_SUBJECT,
} from '../../test/test-data-generators';
@@ -31,12 +30,10 @@
suite('gr-topic-tree-row tests', () => {
let element: GrTopicTreeRow;
- const owner = createAccountWithIdNameAndEmail();
const change: ChangeInfo = {
- ...createChange(),
+ ...createChangeViewChange(),
insertions: 50,
topic: 'myTopic' as TopicName,
- owner,
};
setup(async () => {
@@ -46,12 +43,12 @@
});
test('shows columns of change information', () => {
- const columns = queryAll<HTMLSpanElement>(element, 'span');
+ const columns = queryAll<HTMLTableCellElement>(element, 'td');
assert.equal(columns[0].textContent, 'M');
assert.equal(columns[1].textContent, TEST_SUBJECT);
assert.equal(columns[2].textContent, 'myTopic');
assert.equal(columns[3].textContent, TEST_BRANCH_ID);
- assert.equal(columns[4].textContent, owner.name);
+ assert.equal(columns[4].textContent, 'Test name');
assert.equal(columns[5].textContent, ChangeStatus.NEW);
});
@@ -59,7 +56,7 @@
element.change = {...change, insertions: 0, deletions: 0};
await element.updateComplete;
- const columns = queryAll<HTMLSpanElement>(element, 'span');
+ const columns = queryAll<HTMLTableCellElement>(element, 'td');
assert.equal(columns[0].textContent, '');
});
@@ -67,7 +64,7 @@
element.change = {...change, insertions: 3, deletions: 6};
await element.updateComplete;
- const columns = queryAll<HTMLSpanElement>(element, 'span');
+ const columns = queryAll<HTMLTableCellElement>(element, 'td');
assert.equal(columns[0].textContent, 'XS');
});
@@ -75,7 +72,7 @@
element.change = {...change, insertions: 9, deletions: 40};
await element.updateComplete;
- const columns = queryAll<HTMLSpanElement>(element, 'span');
+ const columns = queryAll<HTMLTableCellElement>(element, 'td');
assert.equal(columns[0].textContent, 'S');
});
@@ -83,7 +80,7 @@
element.change = {...change, insertions: 249, deletions: 0};
await element.updateComplete;
- const columns = queryAll<HTMLSpanElement>(element, 'span');
+ const columns = queryAll<HTMLSpanElement>(element, 'td');
assert.equal(columns[0].textContent, 'M');
});
@@ -91,7 +88,7 @@
element.change = {...change, insertions: 499, deletions: 500};
await element.updateComplete;
- const columns = queryAll<HTMLSpanElement>(element, 'span');
+ const columns = queryAll<HTMLTableCellElement>(element, 'td');
assert.equal(columns[0].textContent, 'L');
});
@@ -99,7 +96,7 @@
element.change = {...change, insertions: 1000, deletions: 1};
await element.updateComplete;
- const columns = queryAll<HTMLSpanElement>(element, 'span');
+ const columns = queryAll<HTMLTableCellElement>(element, 'td');
assert.equal(columns[0].textContent, 'XL');
});
});
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree.ts
index da67e26..a993d90 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import './gr-topic-tree-project';
+import './gr-topic-tree-repo';
import {customElement, property, state} from 'lit/decorators';
import {LitElement, html, PropertyValues} from 'lit-element/lit-element';
import {getAppContext} from '../../services/app-context';
@@ -24,7 +24,7 @@
/**
* A tree-like dashboard showing changes related to a topic, organized by
- * project.
+ * repository.
*/
@customElement('gr-topic-tree')
export class GrTopicTree extends LitElement {
@@ -32,7 +32,7 @@
topicName?: string;
@state()
- private changesByProject = new Map<RepoName, ChangeInfo[]>();
+ private changesByRepo = new Map<RepoName, ChangeInfo[]>();
private restApiService = getAppContext().restApiService;
@@ -44,39 +44,69 @@
}
override render() {
- // TODO: organize into <table> for column alignment.
- return Array.from(this.changesByProject).map(([projectName, changes]) =>
- this.renderProjectSection(projectName, changes)
- );
+ return html`
+ <table>
+ <thead>
+ <tr>
+ <td>Size</td>
+ <td>Subject</td>
+ <td>Topic</td>
+ <td>Branch</td>
+ <td>Owner</td>
+ <td>Status</td>
+ </tr>
+ </thead>
+ <tbody>
+ ${Array.from(this.changesByRepo).map(([repoName, changes]) =>
+ this.renderRepoSection(repoName, changes)
+ )}
+ </tbody>
+ </table>
+ `;
}
- private renderProjectSection(projectName: RepoName, changes: ChangeInfo[]) {
+ private renderRepoSection(repoName: RepoName, changes: ChangeInfo[]) {
return html`
- <gr-topic-tree-project
- .projectName=${projectName}
+ <gr-topic-tree-repo
+ .repoName=${repoName}
.changes=${changes}
- ></gr-topic-tree-project>
+ ></gr-topic-tree-repo>
`;
}
private async loadAndSortChangesFromTopic(): Promise<void> {
- const changes = await this.restApiService.getChanges(
- undefined /* changesPerPage */,
- `topic:${this.topicName}`
+ const changesInTopic = this.topicName
+ ? await this.restApiService.getChangesWithSameTopic(this.topicName)
+ : [];
+ const changesSubmittedTogether = await this.loadChangesSubmittedTogether(
+ changesInTopic
);
- if (!changes) {
- return;
- }
- this.changesByProject.clear();
- for (const change of changes) {
- if (this.changesByProject.has(change.project)) {
- this.changesByProject.get(change.project)!.push(change);
+ this.changesByRepo.clear();
+ for (const change of changesSubmittedTogether) {
+ if (this.changesByRepo.has(change.project)) {
+ this.changesByRepo.get(change.project)!.push(change);
} else {
- this.changesByProject.set(change.project, [change]);
+ this.changesByRepo.set(change.project, [change]);
}
}
this.requestUpdate();
}
+
+ private async loadChangesSubmittedTogether(
+ changesInTopic?: ChangeInfo[]
+ ): Promise<ChangeInfo[]> {
+ // All changes in the topic will be submitted together, so we can use any of
+ // them for the request to getChangesSubmittedTogether as long as the topic
+ // is not empty.
+ if (!changesInTopic || changesInTopic.length === 0) {
+ return [];
+ }
+ const response = await this.restApiService.getChangesSubmittedTogether(
+ changesInTopic[0]._number,
+ ['NON_VISIBLE_CHANGES', 'CURRENT_REVISION', 'CURRENT_COMMIT']
+ );
+ return response?.changes ?? [];
+ }
}
declare global {
diff --git a/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts b/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts
index f214520..72e14b8 100644
--- a/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts
+++ b/polygerrit-ui/app/elements/topic/gr-topic-tree_test.ts
@@ -18,52 +18,84 @@
import {ChangeInfo, RepoName} from '../../api/rest-api';
import '../../test/common-test-setup-karma';
import {createChange} from '../../test/test-data-generators';
-import {queryAll, stubRestApi} from '../../test/test-utils';
+import {mockPromise, queryAll, stubRestApi} from '../../test/test-utils';
+import {SubmittedTogetherInfo} from '../../types/common';
import './gr-topic-tree';
import {GrTopicTree} from './gr-topic-tree';
-import {GrTopicTreeProject} from './gr-topic-tree-project';
+import {GrTopicTreeRepo} from './gr-topic-tree-repo';
const basicFixture = fixtureFromElement('gr-topic-tree');
-function createChangeForProject(projectName: string): ChangeInfo {
- return {...createChange(), project: projectName as RepoName};
+const repo1Name = 'repo1' as RepoName;
+const repo2Name = 'repo2' as RepoName;
+const repo3Name = 'repo3' as RepoName;
+
+function createChangeForRepo(repoName: string): ChangeInfo {
+ return {...createChange(), project: repoName as RepoName};
}
suite('gr-topic-tree tests', () => {
let element: GrTopicTree;
- const project1Changes = [
- createChangeForProject('project1'),
- createChangeForProject('project1'),
+ const repo1ChangeOutsideTopic = createChangeForRepo(repo1Name);
+ const repo1ChangesInTopic = [
+ createChangeForRepo(repo1Name),
+ createChangeForRepo(repo1Name),
];
- const project2Changes = [
- createChangeForProject('project2'),
- createChangeForProject('project2'),
+ const repo2ChangesInTopic = [
+ createChangeForRepo(repo2Name),
+ createChangeForRepo(repo2Name),
];
- const project3Changes = [
- createChangeForProject('project3'),
- createChangeForProject('project3'),
+ const repo3ChangesInTopic = [
+ createChangeForRepo(repo3Name),
+ createChangeForRepo(repo3Name),
];
setup(async () => {
- stubRestApi('getChanges')
- .withArgs(undefined, 'topic:myTopic')
- .resolves([...project1Changes, ...project2Changes, ...project3Changes]);
+ stubRestApi('getChangesWithSameTopic')
+ .withArgs('myTopic')
+ .resolves([
+ ...repo1ChangesInTopic,
+ ...repo2ChangesInTopic,
+ ...repo3ChangesInTopic,
+ ]);
+ const changesSubmittedTogetherPromise =
+ mockPromise<SubmittedTogetherInfo>();
+ stubRestApi('getChangesSubmittedTogether').returns(
+ changesSubmittedTogetherPromise
+ );
element = basicFixture.instantiate();
element.topicName = 'myTopic';
+
+ // The first update will trigger the data to be loaded. The second update
+ // will be rendering the loaded data.
+ await element.updateComplete;
+ changesSubmittedTogetherPromise.resolve({
+ changes: [
+ ...repo1ChangesInTopic,
+ repo1ChangeOutsideTopic,
+ ...repo2ChangesInTopic,
+ ...repo3ChangesInTopic,
+ ],
+ non_visible_changes: 0,
+ });
+ await changesSubmittedTogetherPromise;
await element.updateComplete;
});
- test('groups changes by project', () => {
- const projectSections = queryAll<GrTopicTreeProject>(
+ test('groups changes by repo', () => {
+ const repoSections = queryAll<GrTopicTreeRepo>(
element,
- 'gr-topic-tree-project'
+ 'gr-topic-tree-repo'
);
- assert.lengthOf(projectSections, 3);
- assert.equal(projectSections[0].projectName, 'project1');
- assert.sameMembers(projectSections[0].changes!, project1Changes);
- assert.equal(projectSections[1].projectName, 'project2');
- assert.sameMembers(projectSections[1].changes!, project2Changes);
- assert.equal(projectSections[2].projectName, 'project3');
- assert.sameMembers(projectSections[2].changes!, project3Changes);
+ assert.lengthOf(repoSections, 3);
+ assert.equal(repoSections[0].repoName, repo1Name);
+ assert.sameMembers(repoSections[0].changes!, [
+ ...repo1ChangesInTopic,
+ repo1ChangeOutsideTopic,
+ ]);
+ assert.equal(repoSections[1].repoName, repo2Name);
+ assert.sameMembers(repoSections[1].changes!, repo2ChangesInTopic);
+ assert.equal(repoSections[2].repoName, repo3Name);
+ assert.sameMembers(repoSections[2].changes!, repo3ChangesInTopic);
});
});
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index 229bce3..38ed276 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -15,10 +15,11 @@
* limitations under the License.
*/
-import {getAppContext} from '../services/app-context';
+import {create, Registry, Finalizable} from '../services/registry';
+import {AppContext} from '../services/app-context';
+import {AuthService} from '../services/gr-auth/gr-auth';
import {FlagsService} from '../services/flags/flags';
import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
-import {AuthService} from '../services/gr-auth/gr-auth';
class MockFlagsService implements FlagsService {
isEnabled() {
@@ -63,15 +64,47 @@
// Setup mocks for appContext.
// This is a temporary solution
// TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
- function setMock(serviceName: string, setupMock: unknown) {
- Object.defineProperty(getAppContext(), serviceName, {
- get() {
- return setupMock;
- },
- });
- }
- setMock('flagsService', new MockFlagsService());
- setMock('reportingService', grReportingMock);
- setMock('authService', new MockAuthService());
+export function createDiffAppContext(): AppContext & Finalizable {
+ const appRegistry: Registry<AppContext> = {
+ flagsService: (_ctx: Partial<AppContext>) => new MockFlagsService(),
+ authService: (_ctx: Partial<AppContext>) => new MockAuthService(),
+ reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
+ eventEmitter: (_ctx: Partial<AppContext>) => {
+ throw new Error('eventEmitter is not implemented');
+ },
+ restApiService: (_ctx: Partial<AppContext>) => {
+ throw new Error('restApiService is not implemented');
+ },
+ changeModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('changeModel is not implemented');
+ },
+ commentsModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('commentsModel is not implemented');
+ },
+ checksModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('checksModel is not implemented');
+ },
+ jsApiService: (_ctx: Partial<AppContext>) => {
+ throw new Error('jsApiService is not implemented');
+ },
+ storageService: (_ctx: Partial<AppContext>) => {
+ throw new Error('storageService is not implemented');
+ },
+ configModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('configModel is not implemented');
+ },
+ userModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('userModel is not implemented');
+ },
+ routerModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('routerModel is not implemented');
+ },
+ shortcutsService: (_ctx: Partial<AppContext>) => {
+ throw new Error('shortcutsService is not implemented');
+ },
+ browserModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('browserModel is not implemented');
+ },
+ };
+ return create<AppContext>(appRegistry);
}
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
index 695b16e..bb46484 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
@@ -16,15 +16,11 @@
*/
import '../test/common-test-setup-karma.js';
-import {getAppContext} from '../services/app-context.js';
-import {initDiffAppContext} from './gr-diff-app-context-init.js';
-suite('gr diff app context initializer tests', () => {
- setup(() => {
- initDiffAppContext();
- });
+import {createDiffAppContext} from './gr-diff-app-context-init.js';
+suite('gr diff app context initializer tests', () => {
test('all services initialized and are singletons', () => {
- const appContext = getAppContext();
+ const appContext = createDiffAppContext();
Object.keys(appContext).forEach(serviceName => {
const service = appContext[serviceName];
assert.isNotNull(service);
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 422667a4..64ef214 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -28,11 +28,12 @@
import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
-import {initDiffAppContext} from './gr-diff-app-context-init';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+import {injectAppContext} from '../services/app-context';
// Setup appContext for diff.
// TODO (dmfilippov): find a better solution
-initDiffAppContext();
+injectAppContext(createDiffAppContext());
// Setup global variables for existing usages of this component
window.grdiff = {
GrAnnotation,
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 47d5f03..fbf5b0f 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -20,23 +20,25 @@
import {GrReporting} from './gr-reporting/gr-reporting_impl';
import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
import {Auth} from './gr-auth/gr-auth_impl';
-import {GrRestApiInterface} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
+import {GrRestApiServiceImpl} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
+import {ChangeModel} from './change/change-model';
+import {ChecksModel} from './checks/checks-model';
import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
import {GrStorageService} from './storage/gr-storage_impl';
-import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
+import {UserModel} from './user/user-model';
+import {CommentsModel} from './comments/comments-model';
+import {RouterModel} from './router/router-model';
import {ShortcutsService} from './shortcuts/shortcuts-service';
import {BrowserModel} from './browser/browser-model';
import {assertIsDefined} from '../utils/common-util';
+import {ConfigModel} from './config/config-model';
/**
* The AppContext lazy initializator for all services
*/
export function createAppContext(): AppContext & Finalizable {
const appRegistry: Registry<AppContext> = {
+ routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
flagsService: (_ctx: Partial<AppContext>) =>
new FlagsServiceImplementation(),
reportingService: (ctx: Partial<AppContext>) => {
@@ -51,38 +53,66 @@
restApiService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.authService, 'authService');
assertIsDefined(ctx.flagsService, 'flagsService)');
- return new GrRestApiInterface(ctx.authService!, ctx.flagsService!);
+ return new GrRestApiServiceImpl(ctx.authService!, ctx.flagsService!);
},
- changeService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new ChangeService(ctx.restApiService!);
+ changeModel: (ctx: Partial<AppContext>) => {
+ const routerModel = ctx.routerModel;
+ const restApiService = ctx.restApiService;
+ assertIsDefined(routerModel, 'routerModel');
+ assertIsDefined(restApiService, 'restApiService');
+ return new ChangeModel(routerModel, restApiService);
},
- commentsService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new CommentsService(ctx.restApiService!);
+ commentsModel: (ctx: Partial<AppContext>) => {
+ const routerModel = ctx.routerModel;
+ const changeModel = ctx.changeModel;
+ const restApiService = ctx.restApiService;
+ const reportingService = ctx.reportingService;
+ assertIsDefined(routerModel, 'routerModel');
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(restApiService, 'restApiService');
+ assertIsDefined(reportingService, 'reportingService');
+ return new CommentsModel(
+ routerModel,
+ changeModel,
+ restApiService,
+ reportingService
+ );
},
- checksService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new ChecksService(ctx.reportingService!);
+ checksModel: (ctx: Partial<AppContext>) => {
+ const routerModel = ctx.routerModel;
+ const changeModel = ctx.changeModel;
+ const reportingService = ctx.reportingService;
+ assertIsDefined(routerModel, 'routerModel');
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(reportingService, 'reportingService');
+ return new ChecksModel(routerModel, changeModel, reportingService);
},
jsApiService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new GrJsApiInterface(ctx.reportingService!);
+ const reportingService = ctx.reportingService;
+ assertIsDefined(reportingService, 'reportingService');
+ return new GrJsApiInterface(reportingService!);
},
storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
- configService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new ConfigService(ctx.restApiService!);
+ configModel: (ctx: Partial<AppContext>) => {
+ const changeModel = ctx.changeModel;
+ const restApiService = ctx.restApiService;
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(restApiService, 'restApiService');
+ return new ConfigModel(changeModel, restApiService);
},
- userService: (ctx: Partial<AppContext>) => {
+ userModel: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.restApiService, 'restApiService');
- return new UserService(ctx.restApiService!);
+ return new UserModel(ctx.restApiService!);
},
shortcutsService: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.userModel, 'userModel');
assertIsDefined(ctx.reportingService, 'reportingService');
- return new ShortcutsService(ctx.reportingService!);
+ return new ShortcutsService(ctx.userModel, ctx.reportingService!);
},
- browserModel: (_ctx: Partial<AppContext>) => new BrowserModel(),
+ browserModel: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.userModel, 'userModel');
+ return new BrowserModel(ctx.userModel!);
+ },
};
return create<AppContext>(appRegistry);
}
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 87a18ef..367fee7 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -20,29 +20,31 @@
import {ReportingService} from './gr-reporting/gr-reporting';
import {AuthService} from './gr-auth/gr-auth';
import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
+import {ChangeModel} from './change/change-model';
+import {ChecksModel} from './checks/checks-model';
import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
import {StorageService} from './storage/gr-storage';
-import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
+import {UserModel} from './user/user-model';
+import {CommentsModel} from './comments/comments-model';
+import {RouterModel} from './router/router-model';
import {ShortcutsService} from './shortcuts/shortcuts-service';
import {BrowserModel} from './browser/browser-model';
+import {ConfigModel} from './config/config-model';
export interface AppContext {
+ routerModel: RouterModel;
flagsService: FlagsService;
reportingService: ReportingService;
eventEmitter: EventEmitterService;
authService: AuthService;
restApiService: RestApiService;
- changeService: ChangeService;
- commentsService: CommentsService;
- checksService: ChecksService;
+ changeModel: ChangeModel;
+ commentsModel: CommentsModel;
+ checksModel: ChecksModel;
jsApiService: JsApiService;
storageService: StorageService;
- configService: ConfigService;
- userService: UserService;
+ configModel: ConfigModel;
+ userModel: UserModel;
browserModel: BrowserModel;
shortcutsService: ShortcutsService;
}
diff --git a/polygerrit-ui/app/services/browser/browser-model.ts b/polygerrit-ui/app/services/browser/browser-model.ts
index b15091a..a675cdd 100644
--- a/polygerrit-ui/app/services/browser/browser-model.ts
+++ b/polygerrit-ui/app/services/browser/browser-model.ts
@@ -17,8 +17,8 @@
import {BehaviorSubject, Observable, combineLatest} from 'rxjs';
import {distinctUntilChanged, map} from 'rxjs/operators';
import {Finalizable} from '../registry';
-import {preferenceDiffViewMode$} from '../user/user-model';
import {DiffViewMode} from '../../api/diff';
+import {UserModel} from '../user/user-model';
// This value is somewhat arbitrary and not based on research or calculations.
const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
@@ -42,7 +42,7 @@
return this.privateState$;
}
- constructor() {
+ constructor(readonly userModel: UserModel) {
const screenWidth$ = this.privateState$.pipe(
map(
state =>
@@ -55,7 +55,7 @@
// the user model.
this.diffViewMode$ = combineLatest([
screenWidth$,
- preferenceDiffViewMode$,
+ userModel.preferenceDiffViewMode$,
]).pipe(
map(([isScreenTooSmall, preferenceDiffViewMode]) => {
if (isScreenTooSmall) return DiffViewMode.UNIFIED;
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 84e55f9..7055447 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -15,22 +15,48 @@
* limitations under the License.
*/
-import {PatchSetNum} from '../../types/common';
-import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {
+ combineLatest,
+ from,
+ fromEvent,
+ BehaviorSubject,
+ Observable,
+ Subscription,
+} from 'rxjs';
import {
map,
filter,
withLatestFrom,
distinctUntilChanged,
+ startWith,
+ switchMap,
} from 'rxjs/operators';
-import {routerPatchNum$, routerState$} from '../router/router-model';
+import {RouterModel} from '../router/router-model';
import {
computeAllPatchSets,
computeLatestPatchNum,
} from '../../utils/patch-set-util';
import {ParsedChangeInfo} from '../../types/types';
-interface ChangeState {
+import {ChangeInfo} from '../../types/common';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {Finalizable} from '../registry';
+import {select} from '../../utils/observable-util';
+
+export enum LoadingStatus {
+ NOT_LOADED = 'NOT_LOADED',
+ LOADING = 'LOADING',
+ RELOADING = 'RELOADING',
+ LOADED = 'LOADED',
+}
+
+export interface ChangeState {
+ /**
+ * If `change` is undefined, this must be either NOT_LOADED or LOADING.
+ * If `change` is defined, this must be either LOADED or RELOADING.
+ */
+ loadingStatus: LoadingStatus;
change?: ParsedChangeInfo;
/**
* The name of the file user is viewing in the diff view mode. File path is
@@ -42,115 +68,196 @@
// TODO: Figure out how to best enforce immutability of all states. Use Immer?
// Use DeepReadOnly?
-const initialState: ChangeState = {};
+const initialState: ChangeState = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+};
-const privateState$ = new BehaviorSubject(initialState);
+export class ChangeModel implements Finalizable {
+ private readonly privateState$ = new BehaviorSubject(initialState);
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...initialState});
-}
+ public readonly changeState$: Observable<ChangeState> =
+ this.privateState$.asObservable();
-export function _testOnly_setState(state: ChangeState) {
- 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 changeState$: Observable<ChangeState> = privateState$;
-
-// Must only be used by the change service or whatever is in control of this
-// model.
-export function updateStateChange(change?: ParsedChangeInfo) {
- const current = privateState$.getValue();
- // We want to make it easy for subscribers to react to change changes, so we
- // are explicitly emitting an additional `undefined` when the change number
- // changes. So if you are subscribed to the latestPatchsetNumber for example,
- // then you can rely on emissions even if the old and the new change have the
- // same latestPatchsetNumber.
- if (change !== undefined && current.change !== undefined) {
- if (change._number !== current.change._number) {
- privateState$.next({...current, change: undefined});
- }
- }
- privateState$.next({...current, change});
-}
-
-export function updateStatePath(diffPath?: string) {
- const current = privateState$.getValue();
- privateState$.next({...current, diffPath});
-}
-
-/**
- * If you depend on both, router and change state, then you want to filter out
- * inconsistent state, e.g. router changeNum already updated, change not yet
- * reset to undefined.
- */
-export const changeAndRouterConsistent$ = combineLatest([
- routerState$,
- changeState$,
-]).pipe(
- filter(([routerState, changeState]) => {
- const changeNum = changeState.change?._number;
- const routerChangeNum = routerState.changeNum;
- return changeNum === undefined || changeNum === routerChangeNum;
- }),
- distinctUntilChanged()
-);
-
-export const change$ = changeState$.pipe(
- map(changeState => changeState.change),
- distinctUntilChanged()
-);
-
-export const changeLoading$ = change$.pipe(
- map(change => change === undefined),
- distinctUntilChanged()
-);
-
-export const diffPath$ = changeState$.pipe(
- map(changeState => changeState?.diffPath),
- distinctUntilChanged()
-);
-
-export const changeNum$ = change$.pipe(
- map(change => change?._number),
- distinctUntilChanged()
-);
-
-export const repo$ = change$.pipe(
- map(change => change?.project),
- distinctUntilChanged()
-);
-
-export const labels$ = change$.pipe(
- map(change => change?.labels),
- distinctUntilChanged()
-);
-
-export const latestPatchNum$ = change$.pipe(
- map(change => computeLatestPatchNum(computeAllPatchSets(change))),
- distinctUntilChanged()
-);
-
-/**
- * Emits the current patchset number. If the route does not define the current
- * patchset num, then this selector waits for the change to be defined and
- * returns the number of the latest patchset.
- *
- * Note that this selector can emit a patchNum without the change being
- * available!
- */
-export const currentPatchNum$: Observable<PatchSetNum | undefined> =
- changeAndRouterConsistent$.pipe(
- withLatestFrom(routerPatchNum$, latestPatchNum$),
- map(
- ([_, routerPatchNum, latestPatchNum]) => routerPatchNum || latestPatchNum
- ),
- distinctUntilChanged()
+ public readonly change$ = select(
+ this.privateState$,
+ changeState => changeState.change
);
+
+ public readonly changeLoadingStatus$ = select(
+ this.privateState$,
+ changeState => changeState.loadingStatus
+ );
+
+ public readonly diffPath$ = select(
+ this.privateState$,
+ changeState => changeState?.diffPath
+ );
+
+ public readonly changeNum$ = select(this.change$, change => change?._number);
+
+ public readonly repo$ = select(this.change$, change => change?.project);
+
+ public readonly labels$ = select(this.change$, change => change?.labels);
+
+ public readonly latestPatchNum$ = select(this.change$, change =>
+ computeLatestPatchNum(computeAllPatchSets(change))
+ );
+
+ /**
+ * Emits the current patchset number. If the route does not define the current
+ * patchset num, then this selector waits for the change to be defined and
+ * returns the number of the latest patchset.
+ *
+ * Note that this selector can emit a patchNum without the change being
+ * available!
+ */
+ public readonly currentPatchNum$: Observable<PatchSetNum | undefined> =
+ /**
+ * If you depend on both, router and change state, then you want to filter
+ * out inconsistent state, e.g. router changeNum already updated, change not
+ * yet reset to undefined.
+ */
+ combineLatest([this.routerModel.routerState$, this.changeState$])
+ .pipe(
+ filter(([routerState, changeState]) => {
+ const changeNum = changeState.change?._number;
+ const routerChangeNum = routerState.changeNum;
+ return changeNum === undefined || changeNum === routerChangeNum;
+ }),
+ distinctUntilChanged()
+ )
+ .pipe(
+ withLatestFrom(this.routerModel.routerPatchNum$, this.latestPatchNum$),
+ map(([_, routerPatchN, latestPatchN]) => routerPatchN || latestPatchN),
+ distinctUntilChanged()
+ );
+
+ private subscriptions: Subscription[] = [];
+
+ // For usage in `combineLatest` we need `startWith` such that reload$ has an
+ // initial value.
+ private readonly reload$: Observable<unknown> = fromEvent(
+ document,
+ 'reload'
+ ).pipe(startWith(undefined));
+
+ constructor(
+ readonly routerModel: RouterModel,
+ readonly restApiService: RestApiService
+ ) {
+ this.subscriptions = [
+ combineLatest([this.routerModel.routerChangeNum$, this.reload$])
+ .pipe(
+ map(([changeNum, _]) => changeNum),
+ switchMap(changeNum => {
+ if (changeNum !== undefined) this.updateStateLoading(changeNum);
+ return from(this.restApiService.getChangeDetail(changeNum));
+ })
+ )
+ .subscribe(change => {
+ // The change service is currently a singleton, so we have to be
+ // careful to avoid situations where the application state is
+ // partially set for the old change where the user is coming from,
+ // and partially for the new change where the user is navigating to.
+ // So setting the change explicitly to undefined when the user
+ // moves away from diff and change pages (changeNum === undefined)
+ // helps with that.
+ this.updateStateChange(change ?? undefined);
+ }),
+ ];
+ }
+
+ finalize() {
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
+ }
+
+ // Temporary workaround until path is derived in the model itself.
+ updatePath(diffPath?: string) {
+ const current = this.getState();
+ this.setState({...current, diffPath});
+ }
+
+ /**
+ * Typically you would just subscribe to change$ yourself to get updates. But
+ * sometimes it is nice to also be able to get the current ChangeInfo on
+ * demand. So here it is for your convenience.
+ */
+ getChange() {
+ return this.getState().change;
+ }
+
+ /**
+ * Check whether there is no newer patch than the latest patch that was
+ * available when this change was loaded.
+ *
+ * @return A promise that yields true if the latest patch
+ * has been loaded, and false if a newer patch has been uploaded in the
+ * meantime. The promise is rejected on network error.
+ */
+ fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+ const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+ return this.restApiService.getChangeDetail(change._number).then(detail => {
+ if (!detail) {
+ const error = new Error('Change detail not found.');
+ return Promise.reject(error);
+ }
+ const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+ if (!actualLatest || !knownLatest) {
+ const error = new Error('Unable to check for latest patchset.');
+ return Promise.reject(error);
+ }
+ return {
+ isLatest: actualLatest <= knownLatest,
+ newStatus: change.status !== detail.status ? detail.status : null,
+ newMessages:
+ (change.messages || []).length < (detail.messages || []).length
+ ? detail.messages![detail.messages!.length - 1]
+ : undefined,
+ };
+ });
+ }
+
+ /**
+ * Called when change detail loading is initiated.
+ *
+ * If the change number matches the current change in the state, then
+ * this is a reload. If not, then we not just want to set the state to
+ * LOADING instead of RELOADING, but we also want to set the change to
+ * undefined right away. Otherwise components could see inconsistent state:
+ * a new change number, but an old change.
+ */
+ private updateStateLoading(changeNum: NumericChangeId) {
+ const current = this.getState();
+ const reloading = current.change?._number === changeNum;
+ this.setState({
+ ...current,
+ change: reloading ? current.change : undefined,
+ loadingStatus: reloading
+ ? LoadingStatus.RELOADING
+ : LoadingStatus.LOADING,
+ });
+ }
+
+ // Private but used in tests.
+ updateStateChange(change?: ParsedChangeInfo) {
+ const current = this.getState();
+ this.setState({
+ ...current,
+ change,
+ loadingStatus:
+ change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
+ });
+ }
+
+ getState(): ChangeState {
+ return this.privateState$.getValue();
+ }
+
+ // Private but used in tests
+ setState(state: ChangeState) {
+ this.privateState$.next(state);
+ }
+}
diff --git a/polygerrit-ui/app/services/change/change-model_test.ts b/polygerrit-ui/app/services/change/change-model_test.ts
new file mode 100644
index 0000000..0fb9712
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-model_test.ts
@@ -0,0 +1,259 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+import {ChangeStatus} from '../../constants/constants';
+import '../../test/common-test-setup-karma';
+import {
+ createChange,
+ createChangeMessageInfo,
+ createRevision,
+} from '../../test/test-data-generators';
+import {mockPromise, stubRestApi, waitUntil} from '../../test/test-utils';
+import {CommitId, NumericChangeId, PatchSetNum} from '../../types/common';
+import {ParsedChangeInfo} from '../../types/types';
+import {getAppContext} from '../app-context';
+import {GerritView} from '../router/router-model';
+import {ChangeState, LoadingStatus} from './change-model';
+import {ChangeModel} from './change-model';
+
+suite('change service tests', () => {
+ let changeModel: ChangeModel;
+ let knownChange: ParsedChangeInfo;
+ const testCompleted = new Subject<void>();
+ setup(() => {
+ changeModel = new ChangeModel(
+ getAppContext().routerModel,
+ getAppContext().restApiService
+ );
+ knownChange = {
+ ...createChange(),
+ revisions: {
+ sha1: {
+ ...createRevision(1),
+ description: 'patch 1',
+ _number: 1 as PatchSetNum,
+ },
+ sha2: {
+ ...createRevision(2),
+ description: 'patch 2',
+ _number: 2 as PatchSetNum,
+ },
+ },
+ status: ChangeStatus.NEW,
+ current_revision: 'abc' as CommitId,
+ messages: [],
+ };
+ });
+
+ teardown(() => {
+ testCompleted.next();
+ changeModel.finalize();
+ });
+
+ test('load a change', async () => {
+ const promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
+ assert.equal(stub.callCount, 0);
+ assert.isUndefined(state?.change);
+
+ changeModel.routerModel.setState({
+ view: GerritView.CHANGE,
+ changeNum: knownChange._number,
+ });
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
+ assert.equal(stub.callCount, 1);
+ assert.isUndefined(state?.change);
+
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 1);
+ assert.equal(state?.change, knownChange);
+ });
+
+ test('reload a change', async () => {
+ // setting up a loaded change
+ const promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+ changeModel.routerModel.setState({
+ view: GerritView.CHANGE,
+ changeNum: knownChange._number,
+ });
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+ // Reloading same change
+ document.dispatchEvent(new CustomEvent('reload'));
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.RELOADING);
+ assert.equal(stub.callCount, 2);
+ assert.equal(state?.change, knownChange);
+
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 2);
+ assert.equal(state?.change, knownChange);
+ });
+
+ test('navigating to another change', async () => {
+ // setting up a loaded change
+ let promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+ changeModel.routerModel.setState({
+ view: GerritView.CHANGE,
+ changeNum: knownChange._number,
+ });
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+ // Navigating to other change
+
+ const otherChange: ParsedChangeInfo = {
+ ...knownChange,
+ _number: 123 as NumericChangeId,
+ };
+ promise = mockPromise<ParsedChangeInfo | undefined>();
+ changeModel.routerModel.setState({
+ view: GerritView.CHANGE,
+ changeNum: otherChange._number,
+ });
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
+ assert.equal(stub.callCount, 2);
+ assert.isUndefined(state?.change);
+
+ promise.resolve(otherChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 2);
+ assert.equal(state?.change, otherChange);
+ });
+
+ test('navigating to dashboard', async () => {
+ // setting up a loaded change
+ let promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+ changeModel.routerModel.setState({
+ view: GerritView.CHANGE,
+ changeNum: knownChange._number,
+ });
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+ // Navigating to dashboard
+
+ promise = mockPromise<ParsedChangeInfo | undefined>();
+ promise.resolve(undefined);
+ changeModel.routerModel.setState({
+ view: GerritView.CHANGE,
+ changeNum: undefined,
+ });
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
+ assert.equal(stub.callCount, 2);
+ assert.isUndefined(state?.change);
+
+ // Navigating back from dashboard to change page
+
+ promise = mockPromise<ParsedChangeInfo | undefined>();
+ promise.resolve(knownChange);
+ changeModel.routerModel.setState({
+ view: GerritView.CHANGE,
+ changeNum: knownChange._number,
+ });
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 3);
+ assert.equal(state?.change, knownChange);
+ });
+
+ test('changeModel.fetchChangeUpdates on latest', async () => {
+ stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isTrue(result.isLatest);
+ assert.isNotOk(result.newStatus);
+ assert.isNotOk(result.newMessages);
+ });
+
+ test('changeModel.fetchChangeUpdates not on latest', async () => {
+ const actualChange = {
+ ...knownChange,
+ revisions: {
+ ...knownChange.revisions,
+ sha3: {
+ ...createRevision(3),
+ description: 'patch 3',
+ _number: 3 as PatchSetNum,
+ },
+ },
+ };
+ stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isFalse(result.isLatest);
+ assert.isNotOk(result.newStatus);
+ assert.isNotOk(result.newMessages);
+ });
+
+ test('changeModel.fetchChangeUpdates new status', async () => {
+ const actualChange = {
+ ...knownChange,
+ status: ChangeStatus.MERGED,
+ };
+ stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isTrue(result.isLatest);
+ assert.equal(result.newStatus, ChangeStatus.MERGED);
+ assert.isNotOk(result.newMessages);
+ });
+
+ test('changeModel.fetchChangeUpdates new messages', async () => {
+ const actualChange = {
+ ...knownChange,
+ messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
+ };
+ stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isTrue(result.isLatest);
+ assert.isNotOk(result.newStatus);
+ assert.deepEqual(result.newMessages, {
+ ...createChangeMessageInfo(),
+ message: 'blah blah',
+ });
+ });
+});
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
deleted file mode 100644
index ff417b2..0000000
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {from, Subscription} from 'rxjs';
-import {switchMap} from 'rxjs/operators';
-import {routerChangeNum$} from '../router/router-model';
-import {change$, updateStateChange, updateStatePath} from './change-model';
-import {ParsedChangeInfo} from '../../types/types';
-import {ChangeInfo} from '../../types/common';
-import {
- computeAllPatchSets,
- computeLatestPatchNum,
-} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {Finalizable} from '../registry';
-
-export class ChangeService implements Finalizable {
- private change?: ParsedChangeInfo;
-
- private readonly subscriptions: Subscription[] = [];
-
- constructor(readonly restApiService: RestApiService) {
- // TODO: In the future we will want to make restApiService.getChangeDetail()
- // calls from a switchMap() here. For now just make sure to invalidate the
- // change when no changeNum is set.
- this.subscriptions.push(
- routerChangeNum$
- .pipe(
- // The change service is currently a singleton, so we have to be
- // careful to avoid situations where the application state is
- // partially set for the old change where the user is coming from,
- // and partially for the new change where the user is navigating to.
- // So setting the change explicitly to undefined when the user
- // moves away from diff and change pages (changeNum === undefined)
- // helps with that.
- switchMap(changeNum =>
- from(this.restApiService.getChangeDetail(changeNum))
- )
- )
- .subscribe(change => {
- updateStateChange(change ?? undefined);
- })
- );
- this.subscriptions.push(
- change$.subscribe(change => {
- this.change = change;
- })
- );
- }
-
- finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-
- // Temporary workaround until path is derived in the model itself.
- updatePath(path?: string) {
- updateStatePath(path);
- }
-
- /**
- * Typically you would just subscribe to change$ yourself to get updates. But
- * sometimes it is nice to also be able to get the current ChangeInfo on
- * demand. So here it is for your convenience.
- */
- getChange() {
- return this.change;
- }
-
- /**
- * Check whether there is no newer patch than the latest patch that was
- * available when this change was loaded.
- *
- * @return A promise that yields true if the latest patch
- * has been loaded, and false if a newer patch has been uploaded in the
- * meantime. The promise is rejected on network error.
- */
- fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
- const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
- return this.restApiService.getChangeDetail(change._number).then(detail => {
- if (!detail) {
- const error = new Error('Change detail not found.');
- return Promise.reject(error);
- }
- const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
- if (!actualLatest || !knownLatest) {
- const error = new Error('Unable to check for latest patchset.');
- return Promise.reject(error);
- }
- return {
- isLatest: actualLatest <= knownLatest,
- newStatus: change.status !== detail.status ? detail.status : null,
- newMessages:
- (change.messages || []).length < (detail.messages || []).length
- ? detail.messages![detail.messages!.length - 1]
- : undefined,
- };
- });
- }
-}
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
deleted file mode 100644
index 094362b..0000000
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 {ChangeStatus} from '../../constants/constants';
-import '../../test/common-test-setup-karma';
-import {
- createChange,
- createChangeMessageInfo,
- createRevision,
-} from '../../test/test-data-generators';
-import {stubRestApi, waitUntil} from '../../test/test-utils';
-import {CommitId, PatchSetNum} from '../../types/common';
-import {ParsedChangeInfo} from '../../types/types';
-import {getAppContext} from '../app-context';
-import {
- GerritView,
- _testOnly_setState as setRouterState,
-} from '../router/router-model';
-import {ChangeService} from './change-service';
-
-suite('change service tests', () => {
- let changeService: ChangeService;
- let knownChange: ParsedChangeInfo;
- setup(() => {
- changeService = new ChangeService(getAppContext().restApiService);
- knownChange = {
- ...createChange(),
- revisions: {
- sha1: {
- ...createRevision(1),
- description: 'patch 1',
- _number: 1 as PatchSetNum,
- },
- sha2: {
- ...createRevision(2),
- description: 'patch 2',
- _number: 2 as PatchSetNum,
- },
- },
- status: ChangeStatus.NEW,
- current_revision: 'abc' as CommitId,
- messages: [],
- };
- });
-
- teardown(() => {
- changeService.finalize();
- });
-
- test('changeService switching changes', async () => {
- const change = knownChange;
- const stub = stubRestApi('getChangeDetail').returns(
- Promise.resolve(change)
- );
-
- setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
- waitUntil(() => changeService.getChange() === knownChange);
- assert.equal(stub.callCount, 1);
-
- setRouterState({view: GerritView.DASHBOARD, changeNum: undefined});
- waitUntil(() => changeService.getChange() === undefined);
- assert.equal(stub.callCount, 2);
-
- setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
- waitUntil(() => changeService.getChange() === knownChange);
- assert.equal(stub.callCount, 3);
- });
-
- test('changeService.fetchChangeUpdates on latest', async () => {
- stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isTrue(result.isLatest);
- assert.isNotOk(result.newStatus);
- assert.isNotOk(result.newMessages);
- });
-
- test('changeService.fetchChangeUpdates not on latest', async () => {
- const actualChange = {
- ...knownChange,
- revisions: {
- ...knownChange.revisions,
- sha3: {
- ...createRevision(3),
- description: 'patch 3',
- _number: 3 as PatchSetNum,
- },
- },
- };
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isFalse(result.isLatest);
- assert.isNotOk(result.newStatus);
- assert.isNotOk(result.newMessages);
- });
-
- test('changeService.fetchChangeUpdates new status', async () => {
- const actualChange = {
- ...knownChange,
- status: ChangeStatus.MERGED,
- };
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isTrue(result.isLatest);
- assert.equal(result.newStatus, ChangeStatus.MERGED);
- assert.isNotOk(result.newMessages);
- });
-
- test('changeService.fetchChangeUpdates new messages', async () => {
- const actualChange = {
- ...knownChange,
- messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
- };
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isTrue(result.isLatest);
- assert.isNotOk(result.newStatus);
- assert.deepEqual(result.newMessages, {
- ...createChangeMessageInfo(),
- message: 'blah blah',
- });
- });
-});
diff --git a/polygerrit-ui/app/services/checks/checks-fakes.ts b/polygerrit-ui/app/services/checks/checks-fakes.ts
new file mode 100644
index 0000000..09cd2e7
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-fakes.ts
@@ -0,0 +1,418 @@
+/**
+ * @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 {
+ Action,
+ Category,
+ Link,
+ LinkIcon,
+ RunStatus,
+ TagColor,
+} from '../../api/checks';
+import {CheckRun} from './checks-model';
+
+// TODO(brohlfs): Eventually these fakes should be removed. But they have proven
+// to be super convenient for testing, debugging and demoing, so I would like to
+// keep them around for a few quarters. Maybe remove by EOY 2022?
+
+export const fakeRun0: CheckRun = {
+ pluginName: 'f0',
+ internalRunId: 'f0',
+ checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
+ labelName: 'Presubmit',
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f0r0',
+ category: Category.ERROR,
+ summary: 'I would like to point out this error: 1 is not equal to 2!',
+ links: [
+ {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+ ],
+ tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
+ },
+ {
+ internalResultId: 'f0r1',
+ category: Category.ERROR,
+ summary: 'Running the mighty test has failed by crashing.',
+ message: 'Btw, 1 is also not equal to 3. Did you know?',
+ actions: [
+ {
+ name: 'Ignore',
+ tooltip: 'Ignore this result',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
+ },
+ {
+ name: 'Flag',
+ tooltip: 'Flag this result as totally absolutely really not useful',
+ primary: true,
+ disabled: true,
+ callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
+ },
+ {
+ name: 'Upload',
+ tooltip: 'Upload the result to the super cloud.',
+ primary: false,
+ callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
+ },
+ ],
+ tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
+ links: [
+ {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+ {
+ primary: true,
+ url: 'https://google.com',
+ icon: LinkIcon.DOWNLOAD_MOBILE,
+ },
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
+ ],
+ },
+ ],
+ status: RunStatus.COMPLETED,
+};
+
+export const fakeRun1: CheckRun = {
+ pluginName: 'f1',
+ internalRunId: 'f1',
+ checkName: 'FAKE Super Check',
+ statusLink: 'https://www.google.com/',
+ patchset: 1,
+ labelName: 'Verified',
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f1r0',
+ category: Category.WARNING,
+ summary: 'We think that you could improve this.',
+ message: `There is a lot to be said. A lot. I say, a lot.\n
+ So please keep reading.`,
+ tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+ codePointers: [
+ {
+ path: '/COMMIT_MSG',
+ range: {
+ start_line: 10,
+ start_character: 0,
+ end_line: 10,
+ end_character: 0,
+ },
+ },
+ {
+ path: 'polygerrit-ui/app/api/checks.ts',
+ range: {
+ start_line: 5,
+ start_character: 0,
+ end_line: 7,
+ end_character: 0,
+ },
+ },
+ ],
+ links: [
+ {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+ {
+ primary: true,
+ url: 'https://google.com',
+ icon: LinkIcon.DOWNLOAD_MOBILE,
+ },
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {
+ primary: false,
+ url: 'https://google.com',
+ tooltip: 'look at this',
+ icon: LinkIcon.IMAGE,
+ },
+ {
+ primary: false,
+ url: 'https://google.com',
+ tooltip: 'not at this',
+ icon: LinkIcon.IMAGE,
+ },
+ ],
+ },
+ ],
+ status: RunStatus.RUNNING,
+};
+
+export const fakeRun2: CheckRun = {
+ pluginName: 'f2',
+ internalRunId: 'f2',
+ checkName: 'FAKE Mega Analysis',
+ statusDescription: 'This run is nearly completed, but not quite.',
+ statusLink: 'https://www.google.com/',
+ checkDescription:
+ 'From what the title says you can tell that this check analyses.',
+ checkLink: 'https://www.google.com/',
+ scheduledTimestamp: new Date('2021-04-01T03:14:15'),
+ startedTimestamp: new Date('2021-04-01T04:24:25'),
+ finishedTimestamp: new Date('2021-04-01T04:44:44'),
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ actions: [
+ {
+ name: 'Re-Run',
+ tooltip: 'More powerful run than before',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+ },
+ {
+ name: 'Monetize',
+ primary: true,
+ disabled: true,
+ callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
+ },
+ {
+ name: 'Delete',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
+ },
+ ],
+ results: [
+ {
+ internalResultId: 'f2r0',
+ category: Category.INFO,
+ summary: 'This is looking a bit too large.',
+ message: `We are still looking into how large exactly. Stay tuned.
+And have a look at https://www.google.com!
+
+Or have a look at change 30000.
+Example code:
+ const constable = '';
+ var variable = '';`,
+ tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
+ },
+ ],
+ status: RunStatus.COMPLETED,
+};
+
+export const fakeRun3: CheckRun = {
+ pluginName: 'f3',
+ internalRunId: 'f3',
+ checkName: 'FAKE Critical Observations',
+ status: RunStatus.RUNNABLE,
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+};
+
+export const fakeRun4_1: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.RUNNABLE,
+ attempt: 1,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+};
+
+export const fakeRun4_2: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.COMPLETED,
+ attempt: 2,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f42r0',
+ category: Category.INFO,
+ summary: 'Please eliminate all the TODOs!',
+ },
+ ],
+};
+
+export const fakeRun4_3: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.COMPLETED,
+ attempt: 3,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f43r0',
+ category: Category.ERROR,
+ summary: 'Without eliminating all the TODOs your change will break!',
+ },
+ ],
+};
+
+export const fakeRun4_4: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ checkDescription: 'Shows you the possible eliminations.',
+ checkLink: 'https://www.google.com',
+ status: RunStatus.COMPLETED,
+ statusDescription: 'Everything was eliminated already.',
+ statusLink: 'https://www.google.com',
+ attempt: 40,
+ scheduledTimestamp: new Date('2021-04-02T03:14:15'),
+ startedTimestamp: new Date('2021-04-02T04:24:25'),
+ finishedTimestamp: new Date('2021-04-02T04:25:44'),
+ isSingleAttempt: false,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ results: [
+ {
+ 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'}),
+ },
+ ],
+ },
+ ],
+ actions: [
+ {
+ name: 'Re-Run',
+ tooltip: 'small',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+ },
+ ],
+};
+
+export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
+ const runs: CheckRun[] = [];
+ for (let i = from; i < to; i++) {
+ runs.push(fakeRun4CreateAttempt(i));
+ }
+ return runs;
+}
+
+export function fakeRun4CreateAttempt(attempt: number): CheckRun {
+ return {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.COMPLETED,
+ attempt,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+ results:
+ attempt % 2 === 0
+ ? [
+ {
+ internalResultId: 'f43r0',
+ category: Category.ERROR,
+ summary:
+ 'Without eliminating all the TODOs your change will break!',
+ },
+ ]
+ : [],
+ };
+}
+
+export const fakeRun4Att = [
+ fakeRun4_1,
+ fakeRun4_2,
+ fakeRun4_3,
+ ...fakeRun4CreateAttempts(5, 40),
+ fakeRun4_4,
+];
+
+export const fakeActions: Action[] = [
+ {
+ name: 'Fake Action 1',
+ primary: true,
+ disabled: true,
+ tooltip: 'Tooltip for Fake Action 1',
+ callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
+ },
+ {
+ name: 'Fake Action 2',
+ primary: false,
+ disabled: true,
+ tooltip: 'Tooltip for Fake Action 2',
+ callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
+ },
+ {
+ name: 'Fake Action 3',
+ summary: true,
+ primary: false,
+ tooltip: 'Tooltip for Fake Action 3',
+ callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
+ },
+];
+
+export const fakeLinks: Link[] = [
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Bug Report 1',
+ icon: LinkIcon.REPORT_BUG,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Bug Report 2',
+ icon: LinkIcon.REPORT_BUG,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Link 1',
+ icon: LinkIcon.EXTERNAL,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: false,
+ tooltip: 'Fake Link 2',
+ icon: LinkIcon.EXTERNAL,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Code Link',
+ icon: LinkIcon.CODE,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Image Link',
+ icon: LinkIcon.IMAGE,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Help Link',
+ icon: LinkIcon.HELP_PAGE,
+ },
+];
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index bb6809f..134afd8 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -14,23 +14,48 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-import {BehaviorSubject, Observable} from 'rxjs';
+import {AttemptDetail, createAttemptMap} from './checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {select} from '../../utils/observable-util';
+import {Finalizable} from '../registry';
+import {
+ BehaviorSubject,
+ combineLatest,
+ from,
+ Observable,
+ of,
+ Subject,
+ Subscription,
+ timer,
+} from 'rxjs';
+import {
+ catchError,
+ filter,
+ switchMap,
+ takeUntil,
+ takeWhile,
+ throttleTime,
+ withLatestFrom,
+} from 'rxjs/operators';
import {
Action,
- Category,
CheckResult as CheckResultApi,
CheckRun as CheckRunApi,
Link,
- LinkIcon,
- RunStatus,
- TagColor,
+ ChangeData,
+ ChecksApiConfig,
+ ChecksProvider,
+ FetchResponse,
+ ResponseCode,
} from '../../api/checks';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {PatchSetNumber} from '../../types/common';
-import {AttemptDetail, createAttemptMap} from './checks-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {deepEqual} from '../../utils/deep-util';
+import {ChangeModel} from '../change/change-model';
+import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
+import {getCurrentRevision} from '../../utils/change-util';
+import {getShaByPatchNum} from '../../utils/patch-set-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
+import {Execution} from '../../constants/reporting';
+import {fireAlert, fireEvent} from '../../utils/event-util';
+import {RouterModel} from '../router/router-model';
/**
* The checks model maintains the state of checks for two patchsets: the latest
@@ -83,7 +108,7 @@
// properties. So you can just combine them with {...run, ...result}.
export type RunResult = CheckRun & CheckResult;
-interface ChecksProviderState {
+export interface ChecksProviderState {
pluginName: string;
loading: boolean;
/**
@@ -121,117 +146,101 @@
};
}
-const initialState: ChecksState = {
- pluginStateLatest: {},
- pluginStateSelected: {},
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...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$;
-
-export const checksSelectedPatchsetNumber$ = checksState$.pipe(
- map(state => state.patchsetNumberSelected),
- distinctUntilChanged()
-);
-
-export const checksLatest$ = checksState$.pipe(
- map(state => state.pluginStateLatest),
- distinctUntilChanged()
-);
-
-export const checksSelected$ = checksState$.pipe(
- map(state =>
- state.patchsetNumberSelected
- ? state.pluginStateSelected
- : state.pluginStateLatest
- ),
- distinctUntilChanged()
-);
-
-export const aPluginHasRegistered$ = checksLatest$.pipe(
- map(state => Object.keys(state).length > 0),
- distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingFirstTime$ = checksLatest$.pipe(
- map(state =>
- Object.values(state).some(
- provider => provider.loading && provider.firstTimeLoad
- )
- ),
- distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
- map(state =>
- Object.values(state).some(providerState => providerState.loading)
- ),
- distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
- map(state =>
- Object.values(state).some(providerState => providerState.loading)
- ),
- distinctUntilChanged()
-);
-
-export const errorMessageLatest$ = checksLatest$.pipe(
- map(
- state =>
- Object.values(state).find(
- providerState => providerState.errorMessage !== undefined
- )?.errorMessage
- ),
- distinctUntilChanged()
-);
-
export interface ErrorMessages {
/* Maps plugin name to error message. */
[name: string]: string;
}
-export const errorMessagesLatest$ = checksLatest$.pipe(
- map(state => {
+export class ChecksModel implements Finalizable {
+ private readonly providers: {[name: string]: ChecksProvider} = {};
+
+ private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
+
+ private checkToPluginMap = new Map<string, string>();
+
+ private changeNum?: NumericChangeId;
+
+ private latestPatchNum?: PatchSetNumber;
+
+ private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+ private readonly reloadListener: () => void;
+
+ private readonly visibilityChangeListener: () => void;
+
+ private subscriptions: Subscription[] = [];
+
+ private readonly privateState$ = new BehaviorSubject<ChecksState>({
+ pluginStateLatest: {},
+ pluginStateSelected: {},
+ });
+
+ public checksState$: Observable<ChecksState> =
+ this.privateState$.asObservable();
+
+ public checksSelectedPatchsetNumber$ = select(
+ this.checksState$,
+ state => state.patchsetNumberSelected
+ );
+
+ public checksLatest$ = select(
+ this.checksState$,
+ state => state.pluginStateLatest
+ );
+
+ public checksSelected$ = select(this.checksState$, state =>
+ state.patchsetNumberSelected
+ ? state.pluginStateSelected
+ : state.pluginStateLatest
+ );
+
+ public aPluginHasRegistered$ = select(
+ this.checksLatest$,
+ state => Object.keys(state).length > 0
+ );
+
+ public someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state =>
+ Object.values(state).some(
+ provider => provider.loading && provider.firstTimeLoad
+ )
+ );
+
+ public someProvidersAreLoadingLatest$ = select(this.checksLatest$, state =>
+ Object.values(state).some(providerState => providerState.loading)
+ );
+
+ public someProvidersAreLoadingSelected$ = select(
+ this.checksSelected$,
+ state => Object.values(state).some(providerState => providerState.loading)
+ );
+
+ public errorMessageLatest$ = select(
+ this.checksLatest$,
+
+ state =>
+ Object.values(state).find(
+ providerState => providerState.errorMessage !== undefined
+ )?.errorMessage
+ );
+
+ public errorMessagesLatest$ = select(this.checksLatest$, state => {
const errorMessages: ErrorMessages = {};
for (const providerState of Object.values(state)) {
if (providerState.errorMessage === undefined) continue;
errorMessages[providerState.pluginName] = providerState.errorMessage;
}
return errorMessages;
- }),
- distinctUntilChanged(deepEqual)
-);
+ });
-export const loginCallbackLatest$ = checksLatest$.pipe(
- map(
+ public loginCallbackLatest$ = select(
+ this.checksLatest$,
state =>
Object.values(state).find(
providerState => providerState.loginCallback !== undefined
)?.loginCallback
- ),
- distinctUntilChanged()
-);
+ );
-export const topLevelActionsLatest$ = checksLatest$.pipe(
- map(state =>
+ public topLevelActionsLatest$ = select(this.checksLatest$, state =>
Object.values(state).reduce(
(allActions: Action[], providerState: ChecksProviderState) => [
...allActions,
@@ -239,12 +248,9 @@
],
[]
)
- ),
- distinctUntilChanged<Action[]>(deepEqual)
-);
+ );
-export const topLevelActionsSelected$ = checksSelected$.pipe(
- map(state =>
+ public topLevelActionsSelected$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allActions: Action[], providerState: ChecksProviderState) => [
...allActions,
@@ -252,12 +258,9 @@
],
[]
)
- ),
- distinctUntilChanged<Action[]>(deepEqual)
-);
+ );
-export const topLevelLinksSelected$ = checksSelected$.pipe(
- map(state =>
+ public topLevelLinksSelected$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allLinks: Link[], providerState: ChecksProviderState) => [
...allLinks,
@@ -265,12 +268,9 @@
],
[]
)
- ),
- distinctUntilChanged<Link[]>(deepEqual)
-);
+ );
-export const allRunsLatestPatchset$ = checksLatest$.pipe(
- map(state =>
+ public allRunsLatestPatchset$ = select(this.checksLatest$, state =>
Object.values(state).reduce(
(allRuns: CheckRun[], providerState: ChecksProviderState) => [
...allRuns,
@@ -278,12 +278,9 @@
],
[]
)
- ),
- distinctUntilChanged<CheckRun[]>(deepEqual)
-);
+ );
-export const allRunsSelectedPatchset$ = checksSelected$.pipe(
- map(state =>
+ public allRunsSelectedPatchset$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allRuns: CheckRun[], providerState: ChecksProviderState) => [
...allRuns,
@@ -291,16 +288,14 @@
],
[]
)
- ),
- distinctUntilChanged<CheckRun[]>(deepEqual)
-);
+ );
-export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
- map(runs => runs.filter(run => run.isLatestAttempt))
-);
+ public allRunsLatestPatchsetLatestAttempt$ = select(
+ this.allRunsLatestPatchset$,
+ runs => runs.filter(run => run.isLatestAttempt)
+ );
-export const checkToPluginMap$ = checksLatest$.pipe(
- map(state => {
+ public checkToPluginMap$ = select(this.checksLatest$, state => {
const map = new Map<string, string>();
for (const [pluginName, providerState] of Object.entries(state)) {
for (const run of providerState.runs) {
@@ -308,11 +303,9 @@
}
}
return map;
- })
-);
+ });
-export const allResultsSelected$ = checksSelected$.pipe(
- map(state =>
+ public allResultsSelected$ = select(this.checksSelected$, state =>
Object.values(state)
.reduce(
(allResults: CheckResult[], providerState: ChecksProviderState) => [
@@ -326,569 +319,441 @@
[]
)
.filter(r => r !== undefined)
- )
-);
+ );
-// Must only be used by the checks service or whatever is in control of this
-// model.
-export function updateStateSetProvider(
- pluginName: string,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- pluginName,
- loading: false,
- firstTimeLoad: true,
- runs: [],
- actions: [],
- links: [],
- };
- privateState$.next(nextState);
-}
-
-// TODO(brohlfs): Remove all fake runs once the Checks UI is fully launched.
-// They are just making it easier to develop the UI and always see all the
-// different types/states of runs and results.
-
-export const fakeRun0: CheckRun = {
- pluginName: 'f0',
- internalRunId: 'f0',
- checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
- labelName: 'Presubmit',
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f0r0',
- category: Category.ERROR,
- summary: 'I would like to point out this error: 1 is not equal to 2!',
- links: [
- {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
- ],
- tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
- },
- {
- internalResultId: 'f0r1',
- category: Category.ERROR,
- summary: 'Running the mighty test has failed by crashing.',
- message: 'Btw, 1 is also not equal to 3. Did you know?',
- actions: [
- {
- name: 'Ignore',
- tooltip: 'Ignore this result',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
- },
- {
- name: 'Flag',
- tooltip: 'Flag this result as totally absolutely really not useful',
- primary: true,
- disabled: true,
- callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
- },
- {
- name: 'Upload',
- tooltip: 'Upload the result to the super cloud.',
- primary: false,
- callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
- },
- ],
- tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
- links: [
- {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
- {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
- {
- primary: true,
- url: 'https://google.com',
- icon: LinkIcon.DOWNLOAD_MOBILE,
- },
- {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
- {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
- {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
- ],
- },
- ],
- status: RunStatus.COMPLETED,
-};
-
-export const fakeRun1: CheckRun = {
- pluginName: 'f1',
- internalRunId: 'f1',
- checkName: 'FAKE Super Check',
- statusLink: 'https://www.google.com/',
- patchset: 1,
- labelName: 'Verified',
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f1r0',
- category: Category.WARNING,
- summary: 'We think that you could improve this.',
- message: `There is a lot to be said. A lot. I say, a lot.\n
- So please keep reading.`,
- tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
- codePointers: [
- {
- path: '/COMMIT_MSG',
- range: {
- start_line: 10,
- start_character: 0,
- end_line: 10,
- end_character: 0,
- },
- },
- {
- path: 'polygerrit-ui/app/api/checks.ts',
- range: {
- start_line: 5,
- start_character: 0,
- end_line: 7,
- end_character: 0,
- },
- },
- ],
- links: [
- {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
- {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
- {
- primary: true,
- url: 'https://google.com',
- icon: LinkIcon.DOWNLOAD_MOBILE,
- },
- {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {
- primary: false,
- url: 'https://google.com',
- tooltip: 'look at this',
- icon: LinkIcon.IMAGE,
- },
- {
- primary: false,
- url: 'https://google.com',
- tooltip: 'not at this',
- icon: LinkIcon.IMAGE,
- },
- ],
- },
- ],
- status: RunStatus.RUNNING,
-};
-
-export const fakeRun2: CheckRun = {
- pluginName: 'f2',
- internalRunId: 'f2',
- checkName: 'FAKE Mega Analysis',
- statusDescription: 'This run is nearly completed, but not quite.',
- statusLink: 'https://www.google.com/',
- checkDescription:
- 'From what the title says you can tell that this check analyses.',
- checkLink: 'https://www.google.com/',
- scheduledTimestamp: new Date('2021-04-01T03:14:15'),
- startedTimestamp: new Date('2021-04-01T04:24:25'),
- finishedTimestamp: new Date('2021-04-01T04:44:44'),
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
- actions: [
- {
- name: 'Re-Run',
- tooltip: 'More powerful run than before',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
- },
- {
- name: 'Monetize',
- primary: true,
- disabled: true,
- callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
- },
- {
- name: 'Delete',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
- },
- ],
- results: [
- {
- internalResultId: 'f2r0',
- category: Category.INFO,
- summary: 'This is looking a bit too large.',
- message: `We are still looking into how large exactly. Stay tuned.
-And have a look at https://www.google.com!
-
-Or have a look at change 30000.
-Example code:
- const constable = '';
- var variable = '';`,
- tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
- },
- ],
- status: RunStatus.COMPLETED,
-};
-
-export const fakeRun3: CheckRun = {
- pluginName: 'f3',
- internalRunId: 'f3',
- checkName: 'FAKE Critical Observations',
- status: RunStatus.RUNNABLE,
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
-};
-
-export const fakeRun4_1: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.RUNNABLE,
- attempt: 1,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
-};
-
-export const fakeRun4_2: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.COMPLETED,
- attempt: 2,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f42r0',
- category: Category.INFO,
- summary: 'Please eliminate all the TODOs!',
- },
- ],
-};
-
-export const fakeRun4_3: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.COMPLETED,
- attempt: 3,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f43r0',
- category: Category.ERROR,
- summary: 'Without eliminating all the TODOs your change will break!',
- },
- ],
-};
-
-export const fakeRun4_4: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- checkDescription: 'Shows you the possible eliminations.',
- checkLink: 'https://www.google.com',
- status: RunStatus.COMPLETED,
- statusDescription: 'Everything was eliminated already.',
- statusLink: 'https://www.google.com',
- attempt: 40,
- scheduledTimestamp: new Date('2021-04-02T03:14:15'),
- startedTimestamp: new Date('2021-04-02T04:24:25'),
- finishedTimestamp: new Date('2021-04-02T04:25:44'),
- isSingleAttempt: false,
- isLatestAttempt: true,
- attemptDetails: [],
- results: [
- {
- 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'}),
- },
- ],
- },
- ],
- actions: [
- {
- name: 'Re-Run',
- tooltip: 'small',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
- },
- ],
-};
-
-export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
- const runs: CheckRun[] = [];
- for (let i = from; i < to; i++) {
- runs.push(fakeRun4CreateAttempt(i));
+ constructor(
+ readonly routerModel: RouterModel,
+ readonly changeModel: ChangeModel,
+ readonly reporting: ReportingService
+ ) {
+ this.subscriptions = [
+ this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+ this.checkToPluginMap$.subscribe(map => {
+ this.checkToPluginMap = map;
+ }),
+ combineLatest([
+ this.routerModel.routerPatchNum$,
+ this.changeModel.latestPatchNum$,
+ ]).subscribe(([routerPs, latestPs]) => {
+ this.latestPatchNum = latestPs;
+ if (latestPs === undefined) {
+ this.setPatchset(undefined);
+ } else if (typeof routerPs === 'number') {
+ this.setPatchset(routerPs as PatchSetNumber);
+ } else {
+ this.setPatchset(latestPs);
+ }
+ }),
+ ];
+ this.visibilityChangeListener = () => {
+ this.documentVisibilityChange$.next(undefined);
+ };
+ document.addEventListener(
+ 'visibilitychange',
+ this.visibilityChangeListener
+ );
+ this.reloadListener = () => this.reloadAll();
+ document.addEventListener('reload', this.reloadListener);
}
- return runs;
-}
-export function fakeRun4CreateAttempt(attempt: number): CheckRun {
- return {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.COMPLETED,
- attempt,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
- results:
- attempt % 2 === 0
- ? [
- {
- internalResultId: 'f43r0',
- category: Category.ERROR,
- summary:
- 'Without eliminating all the TODOs your change will break!',
- },
- ]
- : [],
- };
-}
-
-export const fakeRun4Att = [
- fakeRun4_1,
- fakeRun4_2,
- fakeRun4_3,
- ...fakeRun4CreateAttempts(5, 40),
- fakeRun4_4,
-];
-
-export const fakeActions: Action[] = [
- {
- name: 'Fake Action 1',
- primary: true,
- disabled: true,
- tooltip: 'Tooltip for Fake Action 1',
- callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
- },
- {
- name: 'Fake Action 2',
- primary: false,
- disabled: true,
- tooltip: 'Tooltip for Fake Action 2',
- callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
- },
- {
- name: 'Fake Action 3',
- summary: true,
- primary: false,
- tooltip: 'Tooltip for Fake Action 3',
- callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
- },
-];
-
-export const fakeLinks: Link[] = [
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Bug Report 1',
- icon: LinkIcon.REPORT_BUG,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Bug Report 2',
- icon: LinkIcon.REPORT_BUG,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Link 1',
- icon: LinkIcon.EXTERNAL,
- },
- {
- url: 'https://www.google.com',
- primary: false,
- tooltip: 'Fake Link 2',
- icon: LinkIcon.EXTERNAL,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Code Link',
- icon: LinkIcon.CODE,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Image Link',
- icon: LinkIcon.IMAGE,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Help Link',
- icon: LinkIcon.HELP_PAGE,
- },
-];
-
-export function getPluginState(
- state: ChecksState,
- patchset: ChecksPatchset = ChecksPatchset.LATEST
-) {
- if (patchset === ChecksPatchset.LATEST) {
- state.pluginStateLatest = {...state.pluginStateLatest};
- return state.pluginStateLatest;
- } else {
- state.pluginStateSelected = {...state.pluginStateSelected};
- return state.pluginStateSelected;
+ finalize() {
+ document.removeEventListener('reload', this.reloadListener);
+ document.removeEventListener(
+ 'visibilitychange',
+ this.visibilityChangeListener
+ );
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
+ this.privateState$.complete();
}
-}
-export function updateStateSetLoading(
- pluginName: string,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: true,
- };
- privateState$.next(nextState);
-}
-
-export function updateStateSetError(
- pluginName: string,
- errorMessage: string,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: false,
- firstTimeLoad: false,
- errorMessage,
- loginCallback: undefined,
- runs: [],
- actions: [],
- };
- privateState$.next(nextState);
-}
-
-export function updateStateSetNotLoggedIn(
- pluginName: string,
- loginCallback: () => void,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: false,
- firstTimeLoad: false,
- errorMessage: undefined,
- loginCallback,
- runs: [],
- actions: [],
- };
- privateState$.next(nextState);
-}
-
-export function updateStateSetResults(
- pluginName: string,
- runs: CheckRunApi[],
- actions: Action[] = [],
- links: Link[] = [],
- patchset: ChecksPatchset
-) {
- const attemptMap = createAttemptMap(runs);
- for (const attemptInfo of attemptMap.values()) {
- // Per run only one attempt can be undefined, so the '?? -1' is not really
- // relevant for sorting.
- attemptInfo.attempts.sort((a, b) => (a.attempt ?? -1) - (b.attempt ?? -1));
+ // Must only be used by the checks service or whatever is in control of this
+ // model.
+ updateStateSetProvider(pluginName: string, patchset: ChecksPatchset) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ pluginName,
+ loading: false,
+ firstTimeLoad: true,
+ runs: [],
+ actions: [],
+ links: [],
+ };
+ this.privateState$.next(nextState);
}
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: false,
- firstTimeLoad: false,
- errorMessage: undefined,
- loginCallback: undefined,
- runs: runs.map(run => {
- const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
- const attemptInfo = attemptMap.get(run.checkName);
- assertIsDefined(attemptInfo, 'attemptInfo');
- return {
- ...run,
- pluginName,
- internalRunId: runId,
- isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
- isSingleAttempt: attemptInfo.isSingleAttempt,
- attemptDetails: attemptInfo.attempts,
- results: (run.results ?? []).map((result, i) => {
- return {
- ...result,
- internalResultId: `${runId}-${i}`,
- };
- }),
- };
- }),
- actions: [...actions],
- links: [...links],
- };
- 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;
+ getPluginState(
+ state: ChecksState,
+ patchset: ChecksPatchset = ChecksPatchset.LATEST
+ ) {
+ if (patchset === ChecksPatchset.LATEST) {
+ state.pluginStateLatest = {...state.pluginStateLatest};
+ return state.pluginStateLatest;
+ } else {
+ state.pluginStateSelected = {...state.pluginStateSelected};
+ return state.pluginStateSelected;
+ }
+ }
+
+ updateStateSetLoading(pluginName: string, patchset: ChecksPatchset) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: true,
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetError(
+ pluginName: string,
+ errorMessage: string,
+ patchset: ChecksPatchset
+ ) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: false,
+ firstTimeLoad: false,
+ errorMessage,
+ loginCallback: undefined,
+ runs: [],
+ actions: [],
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetNotLoggedIn(
+ pluginName: string,
+ loginCallback: () => void,
+ patchset: ChecksPatchset
+ ) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: false,
+ firstTimeLoad: false,
+ errorMessage: undefined,
+ loginCallback,
+ runs: [],
+ actions: [],
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetResults(
+ pluginName: string,
+ runs: CheckRunApi[],
+ actions: Action[] = [],
+ links: Link[] = [],
+ patchset: ChecksPatchset
+ ) {
+ const attemptMap = createAttemptMap(runs);
+ for (const attemptInfo of attemptMap.values()) {
+ // Per run only one attempt can be undefined, so the '?? -1' is not really
+ // relevant for sorting.
+ attemptInfo.attempts.sort(
+ (a, b) => (a.attempt ?? -1) - (b.attempt ?? -1)
+ );
+ }
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: false,
+ firstTimeLoad: false,
+ errorMessage: undefined,
+ loginCallback: undefined,
+ runs: runs.map(run => {
+ const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
+ const attemptInfo = attemptMap.get(run.checkName);
+ assertIsDefined(attemptInfo, 'attemptInfo');
return {
- ...updatedResult,
- internalResultId: result.internalResultId,
+ ...run,
+ pluginName,
+ internalRunId: runId,
+ isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
+ isSingleAttempt: attemptInfo.isSingleAttempt,
+ attemptDetails: attemptInfo.attempts,
+ results: (run.results ?? []).map((result, i) => {
+ return {
+ ...result,
+ internalResultId: `${runId}-${i}`,
+ };
+ }),
};
- }
- return result;
- });
- return resultUpdated ? {...run, results} : run;
- });
- if (!runUpdated) return;
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- runs,
- };
- privateState$.next(nextState);
-}
+ }),
+ actions: [...actions],
+ links: [...links],
+ };
+ this.privateState$.next(nextState);
+ }
-export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
- const nextState = {...privateState$.getValue()};
- nextState.patchsetNumberSelected = patchsetNumber;
- privateState$.next(nextState);
+ updateStateUpdateResult(
+ pluginName: string,
+ updatedRun: CheckRunApi,
+ updatedResult: CheckResultApi,
+ patchset: ChecksPatchset
+ ) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.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,
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+ const nextState = {...this.privateState$.getValue()};
+ nextState.patchsetNumberSelected = patchsetNumber;
+ this.privateState$.next(nextState);
+ }
+
+ setPatchset(num?: PatchSetNumber) {
+ this.updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
+ }
+
+ reload(pluginName: string) {
+ this.reloadSubjects[pluginName].next();
+ }
+
+ reloadAll() {
+ for (const key of Object.keys(this.providers)) {
+ this.reload(key);
+ }
+ }
+
+ reloadForCheck(checkName?: string) {
+ if (!checkName) return;
+ const plugin = this.checkToPluginMap.get(checkName);
+ if (plugin) this.reload(plugin);
+ }
+
+ updateResult(pluginName: string, run: CheckRunApi, result: CheckResultApi) {
+ this.updateStateUpdateResult(
+ pluginName,
+ run,
+ result,
+ ChecksPatchset.LATEST
+ );
+ this.updateStateUpdateResult(
+ pluginName,
+ run,
+ result,
+ ChecksPatchset.SELECTED
+ );
+ }
+
+ triggerAction(action?: Action, run?: CheckRun) {
+ if (!action?.callback) return;
+ if (!this.changeNum) return;
+ const patchSet = run?.patchset ?? this.latestPatchNum;
+ if (!patchSet) return;
+ const promise = action.callback(
+ this.changeNum,
+ patchSet,
+ run?.attempt,
+ run?.externalId,
+ run?.checkName,
+ action.name
+ );
+ // If plugins return undefined or not a promise, then show no toast.
+ if (!promise?.then) return;
+
+ fireAlert(document, `Triggering action '${action.name}' ...`);
+ from(promise)
+ // If the action takes longer than 5 seconds, then most likely the
+ // user is either not interested or the result not relevant anymore.
+ .pipe(takeUntil(timer(5000)))
+ .subscribe(result => {
+ if (result.errorMessage || result.message) {
+ fireAlert(document, `${result.message ?? result.errorMessage}`);
+ } else {
+ fireEvent(document, 'hide-alert');
+ }
+ if (result.shouldReload) {
+ this.reloadForCheck(run?.checkName);
+ }
+ });
+ }
+
+ register(
+ pluginName: string,
+ provider: ChecksProvider,
+ config: ChecksApiConfig
+ ) {
+ if (this.providers[pluginName]) {
+ console.warn(
+ `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
+ );
+ return;
+ }
+ this.providers[pluginName] = provider;
+ this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+ this.updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+ this.updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
+ this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+ this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+ }
+
+ initFetchingOfData(
+ pluginName: string,
+ config: ChecksApiConfig,
+ patchset: ChecksPatchset
+ ) {
+ const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+ // Various events should trigger fetching checks from the provider:
+ // 1. Change number and patchset number changes.
+ // 2. Specific reload requests.
+ // 3. Regular polling starting with an initial fetch right now.
+ // 4. A hidden Gerrit tab becoming visible.
+ this.subscriptions.push(
+ combineLatest([
+ this.changeModel.changeNum$,
+ patchset === ChecksPatchset.LATEST
+ ? this.changeModel.latestPatchNum$
+ : this.checksSelectedPatchsetNumber$,
+ this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
+ timer(0, pollIntervalMs),
+ this.documentVisibilityChange$,
+ ])
+ .pipe(
+ takeWhile(_ => !!this.providers[pluginName]),
+ filter(_ => document.visibilityState !== 'hidden'),
+ withLatestFrom(this.changeModel.change$),
+ switchMap(
+ ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
+ if (!change || !changeNum || !patchNum) return of(this.empty());
+ if (typeof patchNum !== 'number') return of(this.empty());
+ assertIsDefined(change.revisions, 'change.revisions');
+ const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+ // Sometimes patchNum is updated earlier than change, so change
+ // revisions don't have patchNum yet
+ if (!patchsetSha) return of(this.empty());
+ const data: ChangeData = {
+ changeNumber: changeNum,
+ patchsetNumber: patchNum,
+ patchsetSha,
+ repo: change.project,
+ commitMessage: getCurrentRevision(change)?.commit?.message,
+ changeInfo: change as ChangeInfo,
+ };
+ return this.fetchResults(pluginName, data, patchset);
+ }
+ ),
+ catchError(e => {
+ // This should not happen and is really severe, because it means that
+ // the Observable has terminated and we won't recover from that. No
+ // further attempts to fetch results for this plugin will be made.
+ this.reporting.error(e, `checks-model crash for ${pluginName}`);
+ return of(this.createErrorResponse(pluginName, e));
+ })
+ )
+ .subscribe(response => {
+ switch (response.responseCode) {
+ case ResponseCode.ERROR: {
+ const message = response.errorMessage ?? '-';
+ this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
+ plugin: pluginName,
+ message,
+ });
+ this.updateStateSetError(pluginName, message, patchset);
+ break;
+ }
+ case ResponseCode.NOT_LOGGED_IN: {
+ assertIsDefined(response.loginCallback, 'loginCallback');
+ this.reporting.reportExecution(
+ Execution.CHECKS_API_NOT_LOGGED_IN,
+ {
+ plugin: pluginName,
+ }
+ );
+ this.updateStateSetNotLoggedIn(
+ pluginName,
+ response.loginCallback,
+ patchset
+ );
+ break;
+ }
+ case ResponseCode.OK: {
+ this.updateStateSetResults(
+ pluginName,
+ response.runs ?? [],
+ response.actions ?? [],
+ response.links ?? [],
+ patchset
+ );
+ break;
+ }
+ }
+ })
+ );
+ }
+
+ private empty(): FetchResponse {
+ return {
+ responseCode: ResponseCode.OK,
+ runs: [],
+ };
+ }
+
+ private createErrorResponse(
+ pluginName: string,
+ message: object
+ ): FetchResponse {
+ return {
+ responseCode: ResponseCode.ERROR,
+ errorMessage:
+ `Error message from plugin '${pluginName}':` +
+ ` ${JSON.stringify(message)}`,
+ };
+ }
+
+ private fetchResults(
+ pluginName: string,
+ data: ChangeData,
+ patchset: ChecksPatchset
+ ): Observable<FetchResponse> {
+ this.updateStateSetLoading(pluginName, patchset);
+ const timer = this.reporting.getTimer('ChecksPluginFetch');
+ const fetchPromise = this.providers[pluginName]
+ .fetch(data)
+ .then(response => {
+ timer.end({pluginName});
+ return response;
+ });
+ return from(fetchPromise).pipe(
+ catchError(e => of(this.createErrorResponse(pluginName, e)))
+ );
+ }
}
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
index 0be0451..bb90fe2 100644
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -16,15 +16,9 @@
*/
import '../../test/common-test-setup-karma';
import './checks-model';
-import {
- _testOnly_getState,
- ChecksPatchset,
- updateStateSetLoading,
- updateStateSetProvider,
- updateStateSetResults,
- updateStateUpdateResult,
-} from './checks-model';
+import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
import {Category, CheckRun, RunStatus} from '../../api/checks';
+import {getAppContext} from '../app-context';
const PLUGIN_NAME = 'test-plugin';
@@ -45,14 +39,27 @@
},
];
-function current() {
- return _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
-}
-
suite('checks-model tests', () => {
- test('updateStateSetProvider', () => {
- updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.deepEqual(current(), {
+ let model: ChecksModel;
+
+ let current: ChecksProviderState;
+
+ setup(() => {
+ model = new ChecksModel(
+ getAppContext().routerModel,
+ getAppContext().changeModel,
+ getAppContext().reportingService
+ );
+ model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
+ });
+
+ teardown(() => {
+ model.finalize();
+ });
+
+ test('model.updateStateSetProvider', () => {
+ model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.deepEqual(current, {
pluginName: PLUGIN_NAME,
loading: false,
firstTimeLoad: true,
@@ -63,45 +70,69 @@
});
test('loading and first time load', () => {
- updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.isFalse(current().loading);
- assert.isTrue(current().firstTimeLoad);
- updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.isTrue(current().loading);
- assert.isTrue(current().firstTimeLoad);
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
- assert.isFalse(current().loading);
- assert.isFalse(current().firstTimeLoad);
- updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.isTrue(current().loading);
- assert.isFalse(current().firstTimeLoad);
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
- assert.isFalse(current().loading);
- assert.isFalse(current().firstTimeLoad);
+ model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.isFalse(current.loading);
+ assert.isTrue(current.firstTimeLoad);
+ model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.isTrue(current.loading);
+ assert.isTrue(current.firstTimeLoad);
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ assert.isFalse(current.loading);
+ assert.isFalse(current.firstTimeLoad);
+ model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.isTrue(current.loading);
+ assert.isFalse(current.firstTimeLoad);
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ assert.isFalse(current.loading);
+ assert.isFalse(current.firstTimeLoad);
});
- test('updateStateSetResults', () => {
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
- assert.lengthOf(current().runs, 1);
- assert.lengthOf(current().runs[0].results!, 1);
+ test('model.updateStateSetResults', () => {
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ assert.lengthOf(current.runs, 1);
+ assert.lengthOf(current.runs[0].results!, 1);
});
- test('updateStateUpdateResult', () => {
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+ test('model.updateStateUpdateResult', () => {
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
assert.equal(
- current().runs[0].results![0].summary,
+ current.runs[0].results![0].summary,
RUNS[0]!.results![0].summary
);
const result = RUNS[0].results![0];
const updatedResult = {...result, summary: 'new'};
- updateStateUpdateResult(
+ model.updateStateUpdateResult(
PLUGIN_NAME,
RUNS[0],
updatedResult,
ChecksPatchset.LATEST
);
- assert.lengthOf(current().runs, 1);
- assert.lengthOf(current().runs[0].results!, 1);
- assert.equal(current().runs[0].results![0].summary, 'new');
+ assert.lengthOf(current.runs, 1);
+ assert.lengthOf(current.runs[0].results!, 1);
+ assert.equal(current.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
deleted file mode 100644
index 111036c..0000000
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {
- BehaviorSubject,
- combineLatest,
- from,
- Observable,
- of,
- Subject,
- Subscription,
- timer,
-} from 'rxjs';
-import {
- catchError,
- filter,
- switchMap,
- takeUntil,
- takeWhile,
- throttleTime,
- withLatestFrom,
-} from 'rxjs/operators';
-import {
- Action,
- ChangeData,
- CheckResult,
- CheckRun,
- ChecksApiConfig,
- ChecksProvider,
- FetchResponse,
- ResponseCode,
-} from '../../api/checks';
-import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
-import {
- ChecksPatchset,
- checksSelectedPatchsetNumber$,
- checkToPluginMap$,
- updateStateSetError,
- updateStateSetLoading,
- updateStateSetNotLoggedIn,
- updateStateSetPatchset,
- updateStateSetProvider,
- updateStateSetResults,
- updateStateUpdateResult,
-} from './checks-model';
-import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
-import {Finalizable} from '../registry';
-import {getCurrentRevision} from '../../utils/change-util';
-import {getShaByPatchNum} from '../../utils/patch-set-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {ReportingService} from '../gr-reporting/gr-reporting';
-import {routerPatchNum$} from '../router/router-model';
-import {Execution} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
-
-export class ChecksService implements Finalizable {
- private readonly providers: {[name: string]: ChecksProvider} = {};
-
- private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
-
- private checkToPluginMap = new Map<string, string>();
-
- private changeNum?: NumericChangeId;
-
- private latestPatchNum?: PatchSetNumber;
-
- private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
-
- private readonly reloadListener: () => void;
-
- private readonly subscriptions: Subscription[] = [];
-
- private readonly visibilityChangeListener: () => void;
-
- constructor(readonly reporting: ReportingService) {
- this.subscriptions.push(changeNum$.subscribe(x => (this.changeNum = x)));
- this.subscriptions.push(
- checkToPluginMap$.subscribe(map => {
- this.checkToPluginMap = map;
- })
- );
- this.subscriptions.push(
- combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
- ([routerPs, latestPs]) => {
- this.latestPatchNum = latestPs;
- if (latestPs === undefined) {
- this.setPatchset(undefined);
- } else if (typeof routerPs === 'number') {
- this.setPatchset(routerPs);
- } else {
- this.setPatchset(latestPs);
- }
- }
- )
- );
- this.visibilityChangeListener = () => {
- this.documentVisibilityChange$.next(undefined);
- };
- document.addEventListener(
- 'visibilitychange',
- this.visibilityChangeListener
- );
- this.reloadListener = () => this.reloadAll();
- document.addEventListener('reload', this.reloadListener);
- }
-
- finalize() {
- document.removeEventListener('reload', this.reloadListener);
- document.removeEventListener(
- 'visibilitychange',
- this.visibilityChangeListener
- );
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-
- setPatchset(num?: PatchSetNumber) {
- updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
- }
-
- reload(pluginName: string) {
- this.reloadSubjects[pluginName].next();
- }
-
- reloadAll() {
- Object.keys(this.providers).forEach(key => this.reload(key));
- }
-
- reloadForCheck(checkName?: string) {
- if (!checkName) return;
- const plugin = this.checkToPluginMap.get(checkName);
- if (plugin) this.reload(plugin);
- }
-
- updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
- updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
- updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
- }
-
- triggerAction(action?: Action, run?: CheckRun) {
- if (!action?.callback) return;
- if (!this.changeNum) return;
- const patchSet = run?.patchset ?? this.latestPatchNum;
- if (!patchSet) return;
- const promise = action.callback(
- this.changeNum,
- patchSet,
- run?.attempt,
- run?.externalId,
- run?.checkName,
- action.name
- );
- // If plugins return undefined or not a promise, then show no toast.
- if (!promise?.then) return;
-
- fireAlert(document, `Triggering action '${action.name}' ...`);
- from(promise)
- // If the action takes longer than 5 seconds, then most likely the
- // user is either not interested or the result not relevant anymore.
- .pipe(takeUntil(timer(5000)))
- .subscribe(result => {
- if (result.errorMessage || result.message) {
- fireAlert(document, `${result.message ?? result.errorMessage}`);
- } else {
- fireEvent(document, 'hide-alert');
- }
- if (result.shouldReload) {
- this.reloadForCheck(run?.checkName);
- }
- });
- }
-
- register(
- pluginName: string,
- provider: ChecksProvider,
- config: ChecksApiConfig
- ) {
- if (this.providers[pluginName]) {
- console.warn(
- `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
- );
- return;
- }
- this.providers[pluginName] = provider;
- this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
- updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
- updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
- this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
- this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
- }
-
- initFetchingOfData(
- pluginName: string,
- config: ChecksApiConfig,
- patchset: ChecksPatchset
- ) {
- const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
- // Various events should trigger fetching checks from the provider:
- // 1. Change number and patchset number changes.
- // 2. Specific reload requests.
- // 3. Regular polling starting with an initial fetch right now.
- // 4. A hidden Gerrit tab becoming visible.
- this.subscriptions.push(
- combineLatest([
- changeNum$,
- patchset === ChecksPatchset.LATEST
- ? latestPatchNum$
- : checksSelectedPatchsetNumber$,
- this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
- timer(0, pollIntervalMs),
- this.documentVisibilityChange$,
- ])
- .pipe(
- takeWhile(_ => !!this.providers[pluginName]),
- filter(_ => document.visibilityState !== 'hidden'),
- withLatestFrom(change$),
- switchMap(
- ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
- if (!change || !changeNum || !patchNum) return of(this.empty());
- if (typeof patchNum !== 'number') return of(this.empty());
- assertIsDefined(change.revisions, 'change.revisions');
- const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
- // Sometimes patchNum is updated earlier than change, so change
- // revisions don't have patchNum yet
- if (!patchsetSha) return of(this.empty());
- const data: ChangeData = {
- changeNumber: changeNum,
- patchsetNumber: patchNum,
- patchsetSha,
- repo: change.project,
- commitMessage: getCurrentRevision(change)?.commit?.message,
- changeInfo: change as ChangeInfo,
- };
- return this.fetchResults(pluginName, data, patchset);
- }
- ),
- catchError(e => {
- // This should not happen and is really severe, because it means that
- // the Observable has terminated and we won't recover from that. No
- // further attempts to fetch results for this plugin will be made.
- this.reporting.error(e, `checks-service crash for ${pluginName}`);
- return of(this.createErrorResponse(pluginName, e));
- })
- )
- .subscribe(response => {
- switch (response.responseCode) {
- case ResponseCode.ERROR: {
- const message = response.errorMessage ?? '-';
- this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
- plugin: pluginName,
- message,
- });
- updateStateSetError(pluginName, message, patchset);
- break;
- }
- case ResponseCode.NOT_LOGGED_IN: {
- assertIsDefined(response.loginCallback, 'loginCallback');
- this.reporting.reportExecution(
- Execution.CHECKS_API_NOT_LOGGED_IN,
- {
- plugin: pluginName,
- }
- );
- updateStateSetNotLoggedIn(
- pluginName,
- response.loginCallback,
- patchset
- );
- break;
- }
- case ResponseCode.OK: {
- updateStateSetResults(
- pluginName,
- response.runs ?? [],
- response.actions ?? [],
- response.links ?? [],
- patchset
- );
- break;
- }
- }
- })
- );
- }
-
- private empty(): FetchResponse {
- return {
- responseCode: ResponseCode.OK,
- runs: [],
- };
- }
-
- private createErrorResponse(
- pluginName: string,
- message: object
- ): FetchResponse {
- return {
- responseCode: ResponseCode.ERROR,
- errorMessage:
- `Error message from plugin '${pluginName}':` +
- ` ${JSON.stringify(message)}`,
- };
- }
-
- private fetchResults(
- pluginName: string,
- data: ChangeData,
- patchset: ChecksPatchset
- ): Observable<FetchResponse> {
- updateStateSetLoading(pluginName, patchset);
- const timer = this.reporting.getTimer('ChecksPluginFetch');
- const fetchPromise = this.providers[pluginName]
- .fetch(data)
- .then(response => {
- timer.end({pluginName});
- return response;
- });
- return from(fetchPromise).pipe(
- catchError(e => of(this.createErrorResponse(pluginName, e)))
- );
- }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index ad7865b39..95a1030 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -15,23 +15,53 @@
* limitations under the License.
*/
-import {BehaviorSubject, Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
+import {BehaviorSubject} from 'rxjs';
import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
import {
+ CommentBasics,
CommentInfo,
+ NumericChangeId,
+ PatchSetNum,
+ RevisionId,
+ UrlEncodedCommentId,
PathToCommentsInfoMap,
RobotCommentInfo,
} from '../../types/common';
-import {addPath, DraftInfo} from '../../utils/comment-util';
+import {
+ addPath,
+ DraftInfo,
+ isDraft,
+ isUnsaved,
+ reportingDetails,
+ UnsavedInfo,
+} from '../../utils/comment-util';
+import {deepEqual} from '../../utils/deep-util';
+import {select} from '../../utils/observable-util';
+import {RouterModel} from '../router/router-model';
+import {Finalizable} from '../registry';
+import {combineLatest, Subscription} from 'rxjs';
+import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {Interaction} from '../../constants/reporting';
+import {assertIsDefined} from '../../utils/common-util';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {pluralize} from '../../utils/string-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
-interface CommentState {
+export interface CommentState {
/** undefined means 'still loading' */
comments?: PathToCommentsInfoMap;
/** undefined means 'still loading' */
robotComments?: {[path: string]: RobotCommentInfo[]};
+ // All drafts are DraftInfo objects and have __draft = true set.
+ // Drafts have an id and are known to the backend. Unsaved drafts
+ // (see UnsavedInfo) do NOT belong in the application model.
/** undefined means 'still loading' */
drafts?: {[path: string]: DraftInfo[]};
+ // Ported comments only affect `CommentThread` properties, not individual
+ // comments.
/** undefined means 'still loading' */
portedComments?: PathToCommentsInfoMap;
/** undefined means 'still loading' */
@@ -53,60 +83,179 @@
discardedDrafts: [],
};
-const privateState$ = new BehaviorSubject(initialState);
+const TOAST_DEBOUNCE_INTERVAL = 200;
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...initialState});
+function getSavingMessage(numPending: number, requestFailed?: boolean) {
+ if (requestFailed) {
+ return 'Unable to save draft';
+ }
+ if (numPending === 0) {
+ return 'All changes saved';
+ }
+ return `Saving ${pluralize(numPending, 'draft')}...`;
}
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const commentState$: Observable<CommentState> = privateState$;
-
-export function _testOnly_getState() {
- return privateState$.getValue();
+// Private but used in tests.
+export function setComments(
+ state: CommentState,
+ comments?: {
+ [path: string]: CommentInfo[];
+ }
+): CommentState {
+ const nextState = {...state};
+ if (deepEqual(comments, nextState.comments)) return state;
+ nextState.comments = addPath(comments) || {};
+ return nextState;
}
-export function _testOnly_setState(state: CommentState) {
- privateState$.next(state);
+// Private but used in tests.
+export function setRobotComments(
+ state: CommentState,
+ robotComments?: {
+ [path: string]: RobotCommentInfo[];
+ }
+): CommentState {
+ if (deepEqual(robotComments, state.robotComments)) return state;
+ const nextState = {...state};
+ nextState.robotComments = addPath(robotComments) || {};
+ return nextState;
}
-export const commentsLoading$ = commentState$.pipe(
- map(
+// Private but used in tests.
+export function setDrafts(
+ state: CommentState,
+ drafts?: {[path: string]: DraftInfo[]}
+): CommentState {
+ if (deepEqual(drafts, state.drafts)) return state;
+ const nextState = {...state};
+ nextState.drafts = addPath(drafts);
+ return nextState;
+}
+
+// Private but used in tests.
+export function setPortedComments(
+ state: CommentState,
+ portedComments?: PathToCommentsInfoMap
+): CommentState {
+ if (deepEqual(portedComments, state.portedComments)) return state;
+ const nextState = {...state};
+ nextState.portedComments = portedComments || {};
+ return nextState;
+}
+
+// Private but used in tests.
+export function setPortedDrafts(
+ state: CommentState,
+ portedDrafts?: PathToCommentsInfoMap
+): CommentState {
+ if (deepEqual(portedDrafts, state.portedDrafts)) return state;
+ const nextState = {...state};
+ nextState.portedDrafts = portedDrafts || {};
+ return nextState;
+}
+
+// Private but used in tests.
+export function setDiscardedDraft(
+ state: CommentState,
+ draft: DraftInfo
+): CommentState {
+ const nextState = {...state};
+ nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
+ return nextState;
+}
+
+// Private but used in tests.
+export function deleteDiscardedDraft(
+ state: CommentState,
+ draftID?: string
+): CommentState {
+ const nextState = {...state};
+ const drafts = [...nextState.discardedDrafts];
+ const index = drafts.findIndex(d => d.id === draftID);
+ if (index === -1) {
+ throw new Error('discarded draft not found');
+ }
+ drafts.splice(index, 1);
+ nextState.discardedDrafts = drafts;
+ return nextState;
+}
+
+/** Adds or updates a draft. */
+export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
+ const nextState = {...state};
+ if (!draft.path) throw new Error('draft path undefined');
+ if (!isDraft(draft)) throw new Error('draft is not a draft');
+ if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+
+ nextState.drafts = {...nextState.drafts};
+ const drafts = nextState.drafts;
+ if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+ else drafts[draft.path] = [...drafts[draft.path]];
+ const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
+ if (index !== -1) {
+ drafts[draft.path][index] = draft;
+ } else {
+ drafts[draft.path].push(draft);
+ }
+ return nextState;
+}
+
+export function deleteDraft(
+ state: CommentState,
+ draft: DraftInfo
+): CommentState {
+ const nextState = {...state};
+ if (!draft.path) throw new Error('draft path undefined');
+ if (!isDraft(draft)) throw new Error('draft is not a draft');
+ if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+ nextState.drafts = {...nextState.drafts};
+ const drafts = nextState.drafts;
+ const index = (drafts[draft.path] || []).findIndex(
+ d => d.id && d.id === draft.id
+ );
+ if (index === -1) return state;
+ const discardedDraft = drafts[draft.path][index];
+ drafts[draft.path] = [...drafts[draft.path]];
+ drafts[draft.path].splice(index, 1);
+ return setDiscardedDraft(nextState, discardedDraft);
+}
+
+export class CommentsModel implements Finalizable {
+ private readonly privateState$: BehaviorSubject<CommentState> =
+ new BehaviorSubject(initialState);
+
+ public readonly commentsLoading$ = select(
+ this.privateState$,
commentState =>
commentState.comments === undefined ||
commentState.robotComments === undefined ||
commentState.drafts === undefined
- ),
- distinctUntilChanged()
-);
+ );
-export const comments$ = commentState$.pipe(
- map(commentState => commentState.comments),
- distinctUntilChanged()
-);
+ public readonly comments$ = select(
+ this.privateState$,
+ commentState => commentState.comments
+ );
-export const drafts$ = commentState$.pipe(
- map(commentState => commentState.drafts),
- distinctUntilChanged()
-);
+ public readonly drafts$ = select(
+ this.privateState$,
+ commentState => commentState.drafts
+ );
-export const portedComments$ = commentState$.pipe(
- map(commentState => commentState.portedComments),
- distinctUntilChanged()
-);
+ public readonly portedComments$ = select(
+ this.privateState$,
+ commentState => commentState.portedComments
+ );
-export const discardedDrafts$ = commentState$.pipe(
- map(commentState => commentState.discardedDrafts),
- distinctUntilChanged()
-);
+ public readonly discardedDrafts$ = select(
+ this.privateState$,
+ commentState => commentState.discardedDrafts
+ );
-// Emits a new value even if only a single draft is changed. Components should
-// aim to subsribe to something more specific.
-export const changeComments$ = commentState$.pipe(
- map(
+ // Emits a new value even if only a single draft is changed. Components should
+ // aim to subsribe to something more specific.
+ public readonly changeComments$ = select(
+ this.privateState$,
commentState =>
new ChangeComments(
commentState.comments,
@@ -115,128 +264,283 @@
commentState.portedComments,
commentState.portedDrafts
)
- )
-);
+ );
-export const threads$ = changeComments$.pipe(
- map(changeComments => changeComments.getAllThreadsForChange())
-);
+ public readonly threads$ = select(this.changeComments$, changeComments =>
+ changeComments.getAllThreadsForChange()
+ );
-function publishState(state: CommentState) {
- privateState$.next(state);
-}
-
-/** Called when the change number changes. Wipes out all data from the state. */
-export function updateStateReset() {
- publishState({...initialState});
-}
-
-export function updateStateComments(comments?: {
- [path: string]: CommentInfo[];
-}) {
- const nextState = {...privateState$.getValue()};
- nextState.comments = addPath(comments) || {};
- publishState(nextState);
-}
-
-export function updateStateRobotComments(robotComments?: {
- [path: string]: RobotCommentInfo[];
-}) {
- const nextState = {...privateState$.getValue()};
- nextState.robotComments = addPath(robotComments) || {};
- publishState(nextState);
-}
-
-export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
- const nextState = {...privateState$.getValue()};
- nextState.drafts = addPath(drafts) || {};
- publishState(nextState);
-}
-
-export function updateStatePortedComments(
- portedComments?: PathToCommentsInfoMap
-) {
- const nextState = {...privateState$.getValue()};
- nextState.portedComments = portedComments || {};
- publishState(nextState);
-}
-
-export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
- const nextState = {...privateState$.getValue()};
- nextState.portedDrafts = portedDrafts || {};
- publishState(nextState);
-}
-
-export function updateStateAddDiscardedDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
- publishState(nextState);
-}
-
-export function updateStateUndoDiscardedDraft(draftID?: string) {
- const nextState = {...privateState$.getValue()};
- const drafts = [...nextState.discardedDrafts];
- const index = drafts.findIndex(d => d.id === draftID);
- if (index === -1) {
- throw new Error('discarded draft not found');
+ public thread$(id: UrlEncodedCommentId) {
+ return select(this.threads$, threads => threads.find(t => t.rootId === id));
}
- drafts.splice(index, 1);
- nextState.discardedDrafts = drafts;
- publishState(nextState);
-}
-export function updateStateAddDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- if (!draft.path) throw new Error('draft path undefined');
- nextState.drafts = {...nextState.drafts};
- const drafts = nextState.drafts;
- if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
- else drafts[draft.path] = [...drafts[draft.path]];
- const index = drafts[draft.path].findIndex(
- d =>
- (d.__draftID && d.__draftID === draft.__draftID) ||
- (d.id && d.id === draft.id)
- );
- if (index !== -1) {
- drafts[draft.path][index] = draft;
- } else {
- drafts[draft.path].push(draft);
+ private numPendingDraftRequests = 0;
+
+ private changeNum?: NumericChangeId;
+
+ private patchNum?: PatchSetNum;
+
+ private readonly reloadListener: () => void;
+
+ private readonly subscriptions: Subscription[] = [];
+
+ private drafts: {[path: string]: DraftInfo[]} = {};
+
+ private draftToastTask?: DelayedTask;
+
+ private discardedDrafts: DraftInfo[] = [];
+
+ constructor(
+ readonly routerModel: RouterModel,
+ readonly changeModel: ChangeModel,
+ readonly restApiService: RestApiService,
+ readonly reporting: ReportingService
+ ) {
+ this.subscriptions.push(
+ this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
+ );
+ this.subscriptions.push(
+ this.drafts$.subscribe(x => (this.drafts = x ?? {}))
+ );
+ this.subscriptions.push(
+ this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
+ );
+ this.subscriptions.push(
+ this.routerModel.routerChangeNum$.subscribe(changeNum => {
+ this.changeNum = changeNum;
+ this.setState({...initialState});
+ this.reloadAllComments();
+ })
+ );
+ this.subscriptions.push(
+ combineLatest([
+ this.changeModel.changeNum$,
+ this.changeModel.currentPatchNum$,
+ ]).subscribe(([changeNum, patchNum]) => {
+ this.changeNum = changeNum;
+ this.patchNum = patchNum;
+ this.reloadAllPortedComments();
+ })
+ );
+ this.reloadListener = () => {
+ this.reloadAllComments();
+ this.reloadAllPortedComments();
+ };
+ document.addEventListener('reload', this.reloadListener);
}
- publishState(nextState);
-}
-export function updateStateUpdateDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- if (!draft.path) throw new Error('draft path undefined');
- nextState.drafts = {...nextState.drafts};
- const drafts = nextState.drafts;
- if (!drafts[draft.path])
- throw new Error('draft: trying to edit non-existent draft');
- drafts[draft.path] = [...drafts[draft.path]];
- const index = drafts[draft.path].findIndex(
- d =>
- (d.__draftID && d.__draftID === draft.__draftID) ||
- (d.id && d.id === draft.id)
- );
- if (index === -1) return;
- drafts[draft.path][index] = draft;
- publishState(nextState);
-}
+ finalize() {
+ document.removeEventListener('reload', this.reloadListener!);
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions.splice(0, this.subscriptions.length);
+ }
-export function updateStateDeleteDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- if (!draft.path) throw new Error('draft path undefined');
- nextState.drafts = {...nextState.drafts};
- const drafts = nextState.drafts;
- const index = (drafts[draft.path] || []).findIndex(
- d =>
- (d.__draftID && d.__draftID === draft.__draftID) ||
- (d.id && d.id === draft.id)
- );
- if (index === -1) return;
- const discardedDraft = drafts[draft.path][index];
- drafts[draft.path] = [...drafts[draft.path]];
- drafts[draft.path].splice(index, 1);
- publishState(nextState);
- updateStateAddDiscardedDraft(discardedDraft);
+ // Note that this does *not* reload ported comments.
+ async reloadAllComments() {
+ if (!this.changeNum) return;
+ await Promise.all([
+ this.reloadComments(this.changeNum),
+ this.reloadRobotComments(this.changeNum),
+ this.reloadDrafts(this.changeNum),
+ ]);
+ }
+
+ async reloadAllPortedComments() {
+ if (!this.changeNum) return;
+ if (!this.patchNum) return;
+ await Promise.all([
+ this.reloadPortedComments(this.changeNum, this.patchNum),
+ this.reloadPortedDrafts(this.changeNum, this.patchNum),
+ ]);
+ }
+
+ // visible for testing
+ updateState(reducer: (state: CommentState) => CommentState) {
+ const current = this.privateState$.getValue();
+ this.setState(reducer({...current}));
+ }
+
+ // visible for testing
+ setState(state: CommentState) {
+ this.privateState$.next(state);
+ }
+
+ async reloadComments(changeNum: NumericChangeId): Promise<void> {
+ const comments = await this.restApiService.getDiffComments(changeNum);
+ this.updateState(s => setComments(s, comments));
+ }
+
+ async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
+ const robotComments = await this.restApiService.getDiffRobotComments(
+ changeNum
+ );
+ this.updateState(s => setRobotComments(s, robotComments));
+ }
+
+ async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
+ const drafts = await this.restApiService.getDiffDrafts(changeNum);
+ this.updateState(s => setDrafts(s, drafts));
+ }
+
+ async reloadPortedComments(
+ changeNum: NumericChangeId,
+ patchNum = CURRENT as RevisionId
+ ): Promise<void> {
+ const portedComments = await this.restApiService.getPortedComments(
+ changeNum,
+ patchNum
+ );
+ this.updateState(s => setPortedComments(s, portedComments));
+ }
+
+ async reloadPortedDrafts(
+ changeNum: NumericChangeId,
+ patchNum = CURRENT as RevisionId
+ ): Promise<void> {
+ const portedDrafts = await this.restApiService.getPortedDrafts(
+ changeNum,
+ patchNum
+ );
+ this.updateState(s => setPortedDrafts(s, portedDrafts));
+ }
+
+ async restoreDraft(id: UrlEncodedCommentId) {
+ const found = this.discardedDrafts?.find(d => d.id === id);
+ if (!found) throw new Error('discarded draft not found');
+ const newDraft = {
+ ...found,
+ id: undefined,
+ updated: undefined,
+ __draft: undefined,
+ __unsaved: true,
+ };
+ await this.saveDraft(newDraft);
+ this.updateState(s => deleteDiscardedDraft(s, id));
+ }
+
+ /**
+ * Saves a new or updates an existing draft.
+ * The model will only be updated when a successful response comes back.
+ */
+ async saveDraft(draft: DraftInfo | UnsavedInfo, showToast = true) {
+ assertIsDefined(this.changeNum, 'change number');
+ assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+ if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+
+ // Saving the change number as to make sure that the response is still
+ // relevant when it comes back. The user maybe have navigated away.
+ const changeNum = this.changeNum;
+ this.report(Interaction.SAVE_COMMENT, draft);
+ if (showToast) this.showStartRequest();
+ const result = await this.restApiService.saveDiffDraft(
+ changeNum,
+ draft.patch_set,
+ draft
+ );
+ if (changeNum !== this.changeNum) throw new Error('change changed');
+ if (!result.ok) {
+ if (showToast) this.handleFailedDraftRequest();
+ throw new Error(
+ `Failed to save draft comment: ${JSON.stringify(result)}`
+ );
+ }
+ const obj = await this.restApiService.getResponseObject(result);
+ const savedComment = obj as unknown as CommentInfo;
+ const updatedDraft = {
+ ...draft,
+ id: savedComment.id,
+ updated: savedComment.updated,
+ __draft: true,
+ __unsaved: undefined,
+ };
+ if (showToast) this.showEndRequest();
+ this.updateState(s => setDraft(s, updatedDraft));
+ this.report(Interaction.COMMENT_SAVED, updatedDraft);
+ }
+
+ async discardDraft(draftId: UrlEncodedCommentId) {
+ const draft = this.lookupDraft(draftId);
+ assertIsDefined(this.changeNum, 'change number');
+ assertIsDefined(draft, `draft not found by id ${draftId}`);
+ assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+
+ if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
+ // Saving the change number as to make sure that the response is still
+ // relevant when it comes back. The user maybe have navigated away.
+ const changeNum = this.changeNum;
+ this.report(Interaction.DISCARD_COMMENT, draft);
+ this.showStartRequest();
+ const result = await this.restApiService.deleteDiffDraft(
+ changeNum,
+ draft.patch_set,
+ {id: draft.id}
+ );
+ if (changeNum !== this.changeNum) throw new Error('change changed');
+ if (!result.ok) {
+ this.handleFailedDraftRequest();
+ throw new Error(
+ `Failed to discard draft comment: ${JSON.stringify(result)}`
+ );
+ }
+ this.showEndRequest();
+ this.updateState(s => deleteDraft(s, draft));
+ // We don't store empty discarded drafts and don't need an UNDO then.
+ if (draft.message?.trim()) {
+ fire(document, 'show-alert', {
+ message: 'Draft Discarded',
+ action: 'Undo',
+ callback: () => this.restoreDraft(draft.id),
+ });
+ }
+ this.report(Interaction.COMMENT_DISCARDED, draft);
+ }
+
+ private report(interaction: Interaction, comment: CommentBasics) {
+ const details = reportingDetails(comment);
+ this.reporting.reportInteraction(interaction, details);
+ }
+
+ private showStartRequest() {
+ this.numPendingDraftRequests += 1;
+ this.updateRequestToast();
+ }
+
+ private showEndRequest() {
+ this.numPendingDraftRequests -= 1;
+ this.updateRequestToast();
+ }
+
+ private handleFailedDraftRequest() {
+ this.numPendingDraftRequests -= 1;
+ this.updateRequestToast(/* requestFailed=*/ true);
+ }
+
+ private updateRequestToast(requestFailed?: boolean) {
+ if (this.numPendingDraftRequests === 0 && !requestFailed) {
+ fireEvent(document, 'hide-alert');
+ return;
+ }
+ const message = getSavingMessage(
+ this.numPendingDraftRequests,
+ requestFailed
+ );
+ this.draftToastTask = debounce(
+ this.draftToastTask,
+ () => {
+ // Note: the event is fired on the body rather than this element because
+ // this element may not be attached by the time this executes, in which
+ // case the event would not bubble.
+ fireAlert(document.body, message);
+ },
+ TOAST_DEBOUNCE_INTERVAL
+ );
+ }
+
+ private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+ return Object.values(this.drafts)
+ .flat()
+ .find(d => d.id === id);
+ }
}
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
index 30fc7cf..a8f2118 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -19,17 +19,25 @@
import {UrlEncodedCommentId} from '../../types/common';
import {DraftInfo} from '../../utils/comment-util';
import './comments-model';
+import {CommentsModel} from './comments-model';
+import {deleteDraft} from './comments-model';
+import {Subscription} from 'rxjs';
+import '../../test/common-test-setup-karma';
import {
- updateStateDeleteDraft,
- _testOnly_getState,
- _testOnly_setState,
-} from './comments-model';
+ createComment,
+ createParsedChange,
+ TEST_NUMERIC_CHANGE_ID,
+} from '../../test/test-data-generators';
+import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {getAppContext} from '../app-context';
+import {GerritView} from '../router/router-model';
+import {PathToCommentsInfoMap} from '../../types/common';
suite('comments model tests', () => {
test('updateStateDeleteDraft', () => {
const draft = createDraft();
draft.id = '1' as UrlEncodedCommentId;
- _testOnly_setState({
+ const state = {
comments: {},
robotComments: {},
drafts: {
@@ -38,9 +46,9 @@
portedComments: {},
portedDrafts: {},
discardedDrafts: [],
- });
- updateStateDeleteDraft(draft);
- assert.deepEqual(_testOnly_getState(), {
+ };
+ const output = deleteDraft(state, draft);
+ assert.deepEqual(output, {
comments: {},
robotComments: {},
drafts: {
@@ -52,3 +60,69 @@
});
});
});
+
+suite('change service tests', () => {
+ let subscriptions: Subscription[] = [];
+
+ teardown(() => {
+ for (const s of subscriptions) {
+ s.unsubscribe();
+ }
+ subscriptions = [];
+ });
+
+ test('loads comments', async () => {
+ const model = new CommentsModel(
+ getAppContext().routerModel,
+ getAppContext().changeModel,
+ getAppContext().restApiService,
+ getAppContext().reportingService
+ );
+ const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+ Promise.resolve({'foo.c': [createComment()]})
+ );
+ const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+ Promise.resolve({})
+ );
+ const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+ Promise.resolve({})
+ );
+ const portedCommentsSpy = stubRestApi('getPortedComments').returns(
+ Promise.resolve({'foo.c': [createComment()]})
+ );
+ const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
+ Promise.resolve({})
+ );
+ let comments: PathToCommentsInfoMap = {};
+ subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
+ let portedComments: PathToCommentsInfoMap = {};
+ subscriptions.push(
+ model.portedComments$.subscribe(c => (portedComments = c ?? {}))
+ );
+
+ model.routerModel.updateState({
+ view: GerritView.CHANGE,
+ changeNum: TEST_NUMERIC_CHANGE_ID,
+ });
+ model.changeModel.updateStateChange(createParsedChange());
+
+ await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
+ await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
+ await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
+ await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
+ await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
+ await waitUntil(
+ () => Object.keys(comments).length > 0,
+ 'comment in model not set'
+ );
+ await waitUntil(
+ () => Object.keys(portedComments).length > 0,
+ 'ported comment in model not set'
+ );
+
+ assert.equal(comments['foo.c'].length, 1);
+ assert.equal(comments['foo.c'][0].id, '12345');
+ assert.equal(portedComments['foo.c'].length, 1);
+ assert.equal(portedComments['foo.c'][0].id, '12345');
+ });
+});
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
deleted file mode 100644
index c888cd5..0000000
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-/**
- * @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 {combineLatest, Subscription} from 'rxjs';
-import {NumericChangeId, PatchSetNum, RevisionId} from '../../types/common';
-import {DraftInfo, UIDraft} from '../../utils/comment-util';
-import {fireAlert} from '../../utils/event-util';
-import {CURRENT} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {
- updateStateAddDraft,
- updateStateDeleteDraft,
- updateStateUpdateDraft,
- updateStateComments,
- updateStateRobotComments,
- updateStateDrafts,
- updateStatePortedComments,
- updateStatePortedDrafts,
- updateStateUndoDiscardedDraft,
- discardedDrafts$,
- updateStateReset,
-} from './comments-model';
-import {changeNum$, currentPatchNum$} from '../change/change-model';
-
-import {routerChangeNum$} from '../router/router-model';
-import {Finalizable} from '../registry';
-
-export class CommentsService implements Finalizable {
- private discardedDrafts?: UIDraft[] = [];
-
- private changeNum?: NumericChangeId;
-
- private patchNum?: PatchSetNum;
-
- private readonly reloadListener: () => void;
-
- private readonly subscriptions: Subscription[] = [];
-
- constructor(readonly restApiService: RestApiService) {
- this.subscriptions.push(
- discardedDrafts$.subscribe(
- discardedDrafts => (this.discardedDrafts = discardedDrafts)
- )
- );
- this.subscriptions.push(
- routerChangeNum$.subscribe(changeNum => {
- this.changeNum = changeNum;
- updateStateReset();
- this.reloadAllComments();
- })
- );
- this.subscriptions.push(
- combineLatest([changeNum$, currentPatchNum$]).subscribe(
- ([changeNum, patchNum]) => {
- this.changeNum = changeNum;
- this.patchNum = patchNum;
- this.reloadAllPortedComments();
- }
- )
- );
- this.reloadListener = () => {
- this.reloadAllComments();
- this.reloadAllPortedComments();
- };
- document.addEventListener('reload', this.reloadListener);
- }
-
- finalize() {
- document.removeEventListener('reload', this.reloadListener!);
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-
- // Note that this does *not* reload ported comments.
- reloadAllComments() {
- if (!this.changeNum) return;
- this.reloadComments(this.changeNum);
- this.reloadRobotComments(this.changeNum);
- this.reloadDrafts(this.changeNum);
- }
-
- reloadAllPortedComments() {
- if (!this.changeNum) return;
- if (!this.patchNum) return;
- this.reloadPortedComments(this.changeNum, this.patchNum);
- this.reloadPortedDrafts(this.changeNum, this.patchNum);
- }
-
- reloadComments(changeNum: NumericChangeId): Promise<void> {
- return this.restApiService
- .getDiffComments(changeNum)
- .then(comments => updateStateComments(comments));
- }
-
- reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
- return this.restApiService
- .getDiffRobotComments(changeNum)
- .then(robotComments => updateStateRobotComments(robotComments));
- }
-
- reloadDrafts(changeNum: NumericChangeId): Promise<void> {
- return this.restApiService
- .getDiffDrafts(changeNum)
- .then(drafts => updateStateDrafts(drafts));
- }
-
- reloadPortedComments(
- changeNum: NumericChangeId,
- patchNum = CURRENT as RevisionId
- ): Promise<void> {
- return this.restApiService
- .getPortedComments(changeNum, patchNum)
- .then(portedComments => updateStatePortedComments(portedComments));
- }
-
- reloadPortedDrafts(
- changeNum: NumericChangeId,
- patchNum = CURRENT as RevisionId
- ): Promise<void> {
- return this.restApiService
- .getPortedDrafts(changeNum, patchNum)
- .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
- }
-
- restoreDraft(
- changeNum: NumericChangeId,
- patchNum: PatchSetNum,
- draftID: string
- ) {
- const draft = {...this.discardedDrafts?.find(d => d.id === draftID)};
- if (!draft) throw new Error('discarded draft not found');
- // delete draft ID since we want to treat this as a new draft creation
- delete draft.id;
- this.restApiService
- .saveDiffDraft(changeNum, patchNum, draft)
- .then(result => {
- if (!result.ok) {
- fireAlert(document, 'Unable to restore draft');
- return;
- }
- this.restApiService.getResponseObject(result).then(obj => {
- const resComment = obj as unknown as DraftInfo;
- resComment.patch_set = draft.patch_set;
- updateStateAddDraft(resComment);
- updateStateUndoDiscardedDraft(draftID);
- });
- });
- }
-
- addDraft(draft: DraftInfo) {
- updateStateAddDraft(draft);
- }
-
- cancelDraft(draft: DraftInfo) {
- updateStateUpdateDraft(draft);
- }
-
- editDraft(draft: DraftInfo) {
- updateStateUpdateDraft(draft);
- }
-
- deleteDraft(draft: DraftInfo) {
- updateStateDeleteDraft(draft);
- }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
deleted file mode 100644
index 5fe859f..0000000
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * @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 {Subscription} from 'rxjs';
-import '../../test/common-test-setup-karma';
-import {
- createComment,
- createParsedChange,
- TEST_NUMERIC_CHANGE_ID,
-} from '../../test/test-data-generators';
-import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
-import {getAppContext} from '../app-context';
-import {CommentsService} from './comments-service';
-import {updateStateChange} from '../change/change-model';
-import {
- GerritView,
- updateState as updateRouterState,
-} from '../router/router-model';
-import {comments$, portedComments$} from './comments-model';
-import {PathToCommentsInfoMap} from '../../types/common';
-
-suite('change service tests', () => {
- let subscriptions: Subscription[] = [];
-
- teardown(() => {
- for (const s of subscriptions) {
- s.unsubscribe();
- }
- subscriptions = [];
- });
-
- test('loads comments', async () => {
- new CommentsService(getAppContext().restApiService);
- const diffCommentsSpy = stubRestApi('getDiffComments').returns(
- Promise.resolve({'foo.c': [createComment()]})
- );
- const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
- Promise.resolve({})
- );
- const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
- Promise.resolve({})
- );
- const portedCommentsSpy = stubRestApi('getPortedComments').returns(
- Promise.resolve({'foo.c': [createComment()]})
- );
- const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
- Promise.resolve({})
- );
- let comments: PathToCommentsInfoMap = {};
- subscriptions.push(comments$.subscribe(c => (comments = c ?? {})));
- let portedComments: PathToCommentsInfoMap = {};
- subscriptions.push(
- portedComments$.subscribe(c => (portedComments = c ?? {}))
- );
-
- updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
- updateStateChange(createParsedChange());
-
- await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
- await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
- await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
- await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
- await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
- await waitUntil(
- () => Object.keys(comments).length > 0,
- 'comment in model not set'
- );
- await waitUntil(
- () => Object.keys(portedComments).length > 0,
- 'ported comment in model not set'
- );
-
- assert.equal(comments['foo.c'].length, 1);
- assert.equal(comments['foo.c'][0].id, '12345');
- assert.equal(portedComments['foo.c'].length, 1);
- assert.equal(portedComments['foo.c'][0].id, '12345');
- });
-});
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
index f5e10c5..91a77b8 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -14,40 +14,82 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {ConfigInfo, ServerInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
+import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
+import {BehaviorSubject, from, Observable, of, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {Finalizable} from '../registry';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {select} from '../../utils/observable-util';
-interface ConfigState {
+export interface ConfigState {
repoConfig?: ConfigInfo;
serverConfig?: ServerInfo;
}
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: ConfigState = {};
+export class ConfigModel implements Finalizable {
+ // TODO: Figure out how to best enforce immutability of all states. Use Immer?
+ // Use DeepReadOnly?
+ private initialState: ConfigState = {};
-const privateState$ = new BehaviorSubject(initialState);
+ private privateState$ = new BehaviorSubject(this.initialState);
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const configState$: Observable<ConfigState> = privateState$;
+ // Re-exporting as Observable so that you can only subscribe, but not emit.
+ public configState$: Observable<ConfigState> =
+ this.privateState$.asObservable();
-export function updateRepoConfig(repoConfig?: ConfigInfo) {
- const current = privateState$.getValue();
- privateState$.next({...current, repoConfig});
+ public repoConfig$ = select(
+ this.privateState$,
+ configState => configState.repoConfig
+ );
+
+ public repoCommentLinks$ = select(
+ this.repoConfig$,
+ repoConfig => repoConfig?.commentlinks ?? {}
+ );
+
+ public serverConfig$ = select(
+ this.privateState$,
+ configState => configState.serverConfig
+ );
+
+ private subscriptions: Subscription[];
+
+ constructor(
+ readonly changeModel: ChangeModel,
+ readonly restApiService: RestApiService
+ ) {
+ this.subscriptions = [
+ from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
+ this.updateServerConfig(config);
+ }),
+ this.changeModel.repo$
+ .pipe(
+ switchMap((repo?: RepoName) => {
+ if (repo === undefined) return of(undefined);
+ return from(this.restApiService.getProjectConfig(repo));
+ })
+ )
+ .subscribe((repoConfig?: ConfigInfo) => {
+ this.updateRepoConfig(repoConfig);
+ }),
+ ];
+ }
+
+ updateRepoConfig(repoConfig?: ConfigInfo) {
+ const current = this.privateState$.getValue();
+ this.privateState$.next({...current, repoConfig});
+ }
+
+ updateServerConfig(serverConfig?: ServerInfo) {
+ const current = this.privateState$.getValue();
+ this.privateState$.next({...current, serverConfig});
+ }
+
+ finalize() {
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
+ }
}
-
-export function updateServerConfig(serverConfig?: ServerInfo) {
- const current = privateState$.getValue();
- privateState$.next({...current, serverConfig});
-}
-
-export const repoConfig$ = configState$.pipe(
- map(configState => configState.repoConfig),
- distinctUntilChanged()
-);
-
-export const serverConfig$ = configState$.pipe(
- map(configState => configState.serverConfig),
- distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/config/config-service.ts b/polygerrit-ui/app/services/config/config-service.ts
deleted file mode 100644
index 667f347..0000000
--- a/polygerrit-ui/app/services/config/config-service.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @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 {updateRepoConfig, updateServerConfig} from './config-model';
-import {repo$} from '../change/change-model';
-import {switchMap} from 'rxjs/operators';
-import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
-import {from, of, Subscription} from 'rxjs';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {Finalizable} from '../registry';
-
-export class ConfigService implements Finalizable {
- private readonly subscriptions: Subscription[] = [];
-
- constructor(readonly restApiService: RestApiService) {
- this.subscriptions.push(
- from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
- updateServerConfig(config);
- })
- );
- this.subscriptions.push(
- repo$
- .pipe(
- switchMap((repo?: RepoName) => {
- if (repo === undefined) return of(undefined);
- return from(this.restApiService.getProjectConfig(repo));
- })
- )
- .subscribe((repoConfig?: ConfigInfo) => {
- updateRepoConfig(repoConfig);
- })
- );
- }
-
- finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index b688e8f..a4ab95c 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -30,4 +30,5 @@
CHECKS_DEVELOPER = 'UiFeature__checks_developer',
SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
TOPICS_PAGE = 'UiFeature__topics_page',
+ CHECK_RESULTS_IN_DIFFS = 'UiFeature__check_results_in_diffs',
}
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 679fefc..518716b 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -115,11 +115,6 @@
eventName: string | Interaction,
details?: EventDetails
): void;
- /**
- * A draft interaction was started. Update the time-between-draft-actions
- * timer.
- */
- recordDraftInteraction(): void;
reportErrorDialog(message: string): void;
setRepoName(repoName: string): void;
setChangeId(changeId: NumericChangeId): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 7d03de5..a01e9db 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -98,8 +98,6 @@
[Timing.WEB_COMPONENTS_READY]: 0,
};
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
const SLOW_RPC_THRESHOLD = 500;
export function initErrorReporter(reportingService: ReportingService) {
@@ -282,10 +280,6 @@
private reportChangeId: NumericChangeId | undefined;
- private timers: {timeBetweenDraftActions: Timer | null} = {
- timeBetweenDraftActions: null,
- };
-
private pending: PendingReportInfo[] = [];
private slowRpcList: SlowRpcCall[] = [];
@@ -855,27 +849,6 @@
this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
}
- /**
- * A draft interaction was started. Update the time-between-draft-actions
- * Timing.
- */
- recordDraftInteraction() {
- // If there is no timer defined, then this is the first interaction.
- // Set up the timer so that it's ready to record the intervening time when
- // called again.
- const timer = this.timers.timeBetweenDraftActions;
- if (!timer) {
- // Create a timer with a maximum length.
- this.timers.timeBetweenDraftActions = this.getTimer(
- DRAFT_ACTION_TIMER
- ).withMaximum(DRAFT_ACTION_TIMER_MAX);
- return;
- }
-
- // Mark the time and reinitialize the timer.
- timer.end().reset();
- }
-
error(error: Error, errorSource?: string, details?: EventDetails) {
const eventDetails = details ?? {};
const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 485402b..2a5c532 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -59,7 +59,6 @@
pluginLoaded: () => {},
pluginsLoaded: () => {},
pluginsFailed: () => {},
- recordDraftInteraction: () => {},
reporter: () => {},
reportErrorDialog: (message: string) => {
log(`reportErrorDialog: ${message}`);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index f6e87f9..8068dc00 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -282,30 +282,6 @@
assert.isTrue(service.reporter.calledOnce);
});
- test('recordDraftInteraction', () => {
- const key = 'TimeBetweenDraftActions';
- const nowStub = sinon.stub(window.performance, 'now').returns(100);
- const timingStub = sinon.stub(service, '_reportTiming');
- service.recordDraftInteraction();
- assert.isFalse(timingStub.called);
-
- nowStub.returns(200);
- service.recordDraftInteraction();
- assert.isTrue(timingStub.calledOnce);
- assert.equal(timingStub.lastCall.args[0], key);
- assert.equal(timingStub.lastCall.args[1], 100);
-
- nowStub.returns(350);
- service.recordDraftInteraction();
- assert.isTrue(timingStub.calledTwice);
- assert.equal(timingStub.lastCall.args[0], key);
- assert.equal(timingStub.lastCall.args[1], 150);
-
- nowStub.returns(370 + 2 * 60 * 1000);
- service.recordDraftInteraction();
- assert.isFalse(timingStub.calledThrice);
- });
-
test('timeEndWithAverage', () => {
const nowStub = sinon.stub(window.performance, 'now').returns(0);
nowStub.returns(1000);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 837cab6..40ef0ed 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -109,6 +109,7 @@
} from '../../types/diff';
import {ParsedChangeInfo} from '../../types/types';
import {ErrorCallback} from '../../api/rest';
+import {DraftInfo} from '../../utils/comment-util';
export type CancelConditionCallback = () => boolean;
@@ -402,10 +403,6 @@
draft: CommentInput
): Promise<Response>;
- getDiffChangeDetail(
- changeNum: NumericChangeId
- ): Promise<ChangeInfo | undefined | null>;
-
getPortedComments(
changeNum: NumericChangeId,
revision: RevisionId
@@ -454,21 +451,7 @@
getDiffDrafts(
changeNum: NumericChangeId
- ): Promise<PathToCommentsInfoMap | undefined>;
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum: PatchSetNum,
- patchNum: PatchSetNum,
- path: string
- ): Promise<GetDiffCommentsOutput>;
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum?: BasePatchSetNum,
- patchNum?: PatchSetNum,
- path?: string
- ):
- | Promise<GetDiffCommentsOutput>
- | Promise<PathToCommentsInfoMap | undefined>;
+ ): Promise<{[path: string]: DraftInfo[]} | undefined>;
createGroup(config: GroupInput & {name: string}): Promise<Response>;
@@ -648,7 +631,8 @@
): Promise<RelatedChangesInfo | undefined>;
getChangesSubmittedTogether(
- changeNum: NumericChangeId
+ changeNum: NumericChangeId,
+ options?: string[]
): Promise<SubmittedTogetherInfo | undefined>;
getChangeConflicts(
@@ -663,7 +647,10 @@
getChangesWithSameTopic(
topic: string,
- changeNum: NumericChangeId
+ options?: {
+ openChangesOnly?: boolean;
+ changeToExclude?: NumericChangeId;
+ }
): Promise<ChangeInfo[] | undefined>;
getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index ab204a2..e7de1ef 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -40,7 +40,9 @@
for (const key of Object.getOwnPropertyNames(registry)) {
const name = key as keyof TContext;
try {
- (this[name] as unknown as Finalizable).finalize();
+ if (this[name]) {
+ (this[name] as unknown as Finalizable).finalize();
+ }
} catch (e) {
console.info(`Failed to finalize ${name}`);
throw e;
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index f549e859..73dee78 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -15,9 +15,10 @@
* limitations under the License.
*/
-import {NumericChangeId, PatchSetNum} from '../../types/common';
import {BehaviorSubject, Observable} from 'rxjs';
import {distinctUntilChanged, map} from 'rxjs/operators';
+import {Finalizable} from '../registry';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
export enum GerritView {
ADMIN = 'admin',
@@ -42,57 +43,44 @@
patchNum?: PatchSetNum;
}
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: RouterState = {};
+export class RouterModel implements Finalizable {
+ private readonly privateState$ = new BehaviorSubject<RouterState>({});
-const privateState$ = new BehaviorSubject<RouterState>(initialState);
+ readonly routerView$: Observable<GerritView | undefined>;
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...initialState});
+ readonly routerChangeNum$: Observable<NumericChangeId | undefined>;
+
+ readonly routerPatchNum$: Observable<PatchSetNum | undefined>;
+
+ constructor() {
+ this.routerView$ = this.privateState$.pipe(
+ map(state => state.view),
+ distinctUntilChanged()
+ );
+ this.routerChangeNum$ = this.privateState$.pipe(
+ map(state => state.changeNum),
+ distinctUntilChanged()
+ );
+ this.routerPatchNum$ = this.privateState$.pipe(
+ map(state => state.patchNum),
+ distinctUntilChanged()
+ );
+ }
+
+ finalize() {}
+
+ setState(state: RouterState) {
+ this.privateState$.next(state);
+ }
+
+ updateState(partial: Partial<RouterState>) {
+ this.privateState$.next({
+ ...this.privateState$.getValue(),
+ ...partial,
+ });
+ }
+
+ get routerState$(): Observable<RouterState> {
+ return this.privateState$;
+ }
}
-
-export function _testOnly_setState(state: RouterState) {
- 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 routerState$: Observable<RouterState> = privateState$;
-
-// Must only be used by the router service or whatever is in control of this
-// model.
-// TODO: Consider keeping params of type AppElementParams entirely in the state
-export function updateState(
- view?: GerritView,
- changeNum?: NumericChangeId,
- patchNum?: PatchSetNum
-) {
- privateState$.next({
- ...privateState$.getValue(),
- view,
- changeNum,
- patchNum,
- });
-}
-
-export const routerView$ = routerState$.pipe(
- map(state => state.view),
- distinctUntilChanged()
-);
-
-export const routerChangeNum$ = routerState$.pipe(
- map(state => state.changeNum),
- distinctUntilChanged()
-);
-
-export const routerPatchNum$ = routerState$.pipe(
- map(state => state.patchNum),
- distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 1f9e083..e61ab15 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -15,13 +15,13 @@
* limitations under the License.
*/
import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
import {
config,
Shortcut,
ShortcutHelpItem,
ShortcutSection,
} from './shortcuts-config';
-import {disableShortcuts$} from '../user/user-model';
import {
ComboKey,
eventMatchesShortcut,
@@ -33,6 +33,7 @@
} from '../../utils/dom-util';
import {ReportingService} from '../gr-reporting/gr-reporting';
import {Finalizable} from '../registry';
+import {UserModel} from '../user/user-model';
export type SectionView = Array<{binding: string[][]; text: string}>;
@@ -98,7 +99,10 @@
private readonly subscriptions: Subscription[] = [];
- constructor(readonly reporting?: ReportingService) {
+ constructor(
+ readonly userModel: UserModel,
+ readonly reporting?: ReportingService
+ ) {
for (const section of config.keys()) {
const items = config.get(section) ?? [];
for (const item of items) {
@@ -106,11 +110,16 @@
}
}
this.subscriptions.push(
- disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x))
+ this.userModel.preferences$
+ .pipe(
+ map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
+ distinctUntilChanged()
+ )
+ .subscribe(x => (this.shortcutsDisabled = x))
);
this.keydownListener = (e: KeyboardEvent) => {
if (!isComboKey(e.key)) return;
- if (this.shouldSuppress(e)) return;
+ if (this.shortcutsDisabled || shouldSuppress(e)) return;
this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
};
document.addEventListener('keydown', this.keydownListener);
@@ -150,7 +159,12 @@
addShortcut(
element: HTMLElement,
shortcut: Binding,
- listener: (e: KeyboardEvent) => void
+ listener: (e: KeyboardEvent) => void,
+ options: {
+ shouldSuppress: boolean;
+ } = {
+ shouldSuppress: true,
+ }
) {
const wrappedListener = (e: KeyboardEvent) => {
if (e.repeat && !shortcut.allowRepeat) return;
@@ -160,19 +174,21 @@
} else {
if (this.isInComboKeyMode()) return;
}
- if (this.shouldSuppress(e)) return;
+ if (options.shouldSuppress && shouldSuppress(e)) return;
+ // `shortcutsDisabled` refers to disabling global shortcuts like 'n'. If
+ // `shouldSuppress` is false (e.g.for Ctrl - ENTER), then don't disable
+ // the shortcut.
+ if (options.shouldSuppress && this.shortcutsDisabled) return;
e.preventDefault();
e.stopPropagation();
+ this.reportTriggered(e);
listener(e);
};
element.addEventListener('keydown', wrappedListener);
return () => element.removeEventListener('keydown', wrappedListener);
}
- shouldSuppress(e: KeyboardEvent) {
- if (this.shortcutsDisabled) return true;
- if (shouldSuppress(e)) return true;
-
+ private reportTriggered(e: KeyboardEvent) {
// eg: {key: "k:keydown", ..., from: "gr-diff-view"}
let key = `${e.key}:${e.type}`;
if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -186,7 +202,6 @@
from = e.currentTarget.tagName;
}
this.reporting?.reportInteraction('shortcut-triggered', {key, from});
- return false;
}
createTitle(shortcutName: Shortcut, section: ShortcutSection) {
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index a024159..7dd3f75 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -21,80 +21,18 @@
ShortcutsService,
} from '../../services/shortcuts/shortcuts-service';
import {Shortcut, ShortcutSection} from './shortcuts-config';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
import {SinonFakeTimers} from 'sinon';
import {Key, Modifier} from '../../utils/dom-util';
-
-async function keyEventOn(
- el: HTMLElement,
- callback: (e: KeyboardEvent) => void,
- keyCode = 75,
- key = 'k'
-): Promise<KeyboardEvent> {
- let resolve: (e: KeyboardEvent) => void;
- const promise = new Promise<KeyboardEvent>(r => (resolve = r));
- el.addEventListener('keydown', (e: KeyboardEvent) => {
- callback(e);
- resolve(e);
- });
- MockInteractions.keyDownOn(el, keyCode, null, key);
- return await promise;
-}
+import {getAppContext} from '../app-context';
suite('shortcuts-service tests', () => {
let service: ShortcutsService;
setup(() => {
- service = new ShortcutsService();
- });
-
- suite('shouldSuppress', () => {
- test('do not suppress shortcut event from <div>', async () => {
- await keyEventOn(document.createElement('div'), e => {
- assert.isFalse(service.shouldSuppress(e));
- });
- });
-
- test('suppress shortcut event from <input>', async () => {
- await keyEventOn(document.createElement('input'), e => {
- assert.isTrue(service.shouldSuppress(e));
- });
- });
-
- test('suppress shortcut event from <textarea>', async () => {
- await keyEventOn(document.createElement('textarea'), e => {
- assert.isTrue(service.shouldSuppress(e));
- });
- });
-
- test('do not suppress shortcut event from checkbox <input>', async () => {
- const inputEl = document.createElement('input');
- inputEl.setAttribute('type', 'checkbox');
- await keyEventOn(inputEl, e => {
- assert.isFalse(service.shouldSuppress(e));
- });
- });
-
- test('suppress shortcut event from children of <gr-overlay>', async () => {
- const overlay = document.createElement('gr-overlay');
- const div = document.createElement('div');
- overlay.appendChild(div);
- await keyEventOn(div, e => {
- assert.isTrue(service.shouldSuppress(e));
- });
- });
-
- test('suppress "enter" shortcut event from <a>', async () => {
- await keyEventOn(document.createElement('a'), e => {
- assert.isFalse(service.shouldSuppress(e));
- });
- await keyEventOn(
- document.createElement('a'),
- e => assert.isTrue(service.shouldSuppress(e)),
- 13,
- 'enter'
- );
- });
+ service = new ShortcutsService(
+ getAppContext().userModel,
+ getAppContext().reportingService
+ );
});
test('getShortcut', () => {
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index f772ebe..441c09d 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -14,101 +14,175 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
+import {from, of, BehaviorSubject, Observable, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {
+ DiffPreferencesInfo as DiffPreferencesInfoAPI,
+ DiffViewMode,
+} from '../../api/diff';
+import {
+ AccountCapabilityInfo,
+ AccountDetailInfo,
+ PreferencesInfo,
+} from '../../types/common';
import {
createDefaultPreferences,
createDefaultDiffPrefs,
} from '../../constants/constants';
-import {DiffPreferencesInfo, DiffViewMode} from '../../api/diff';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {DiffPreferencesInfo} from '../../types/diff';
+import {Finalizable} from '../registry';
+import {select} from '../../utils/observable-util';
-interface UserState {
+export interface UserState {
/**
* Keeps being defined even when credentials have expired.
*/
account?: AccountDetailInfo;
preferences: PreferencesInfo;
diffPreferences: DiffPreferencesInfo;
+ capabilities?: AccountCapabilityInfo;
}
-const initialState: UserState = {
- preferences: createDefaultPreferences(),
- diffPreferences: createDefaultDiffPrefs(),
-};
+export class UserModel implements Finalizable {
+ private readonly privateState$: BehaviorSubject<UserState> =
+ new BehaviorSubject({
+ preferences: createDefaultPreferences(),
+ diffPreferences: createDefaultDiffPrefs(),
+ });
-const privateState$ = new BehaviorSubject(initialState);
+ readonly account$: Observable<AccountDetailInfo | undefined> = select(
+ this.privateState$,
+ userState => userState.account
+ );
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...initialState});
+ /** Note that this may still be true, even if credentials have expired. */
+ readonly loggedIn$: Observable<boolean> = select(
+ this.account$,
+ account => !!account
+ );
+
+ readonly capabilities$: Observable<AccountCapabilityInfo | undefined> =
+ select(this.userState$, userState => userState.capabilities);
+
+ readonly isAdmin$: Observable<boolean> = select(
+ this.capabilities$,
+ capabilities => capabilities?.administrateServer ?? false
+ );
+
+ readonly preferences$: Observable<PreferencesInfo> = select(
+ this.privateState$,
+ userState => userState.preferences
+ );
+
+ readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
+ this.privateState$,
+ userState => userState.diffPreferences
+ );
+
+ readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
+ this.preferences$,
+ preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE
+ );
+
+ private subscriptions: Subscription[] = [];
+
+ get userState$(): Observable<UserState> {
+ return this.privateState$;
+ }
+
+ constructor(readonly restApiService: RestApiService) {
+ this.subscriptions = [
+ from(this.restApiService.getAccount()).subscribe(
+ (account?: AccountDetailInfo) => {
+ this.setAccount(account);
+ }
+ ),
+ this.account$
+ .pipe(
+ switchMap(account => {
+ if (!account) return of(createDefaultPreferences());
+ return from(this.restApiService.getPreferences());
+ })
+ )
+ .subscribe((preferences?: PreferencesInfo) => {
+ this.setPreferences(preferences ?? createDefaultPreferences());
+ }),
+ this.account$
+ .pipe(
+ switchMap(account => {
+ if (!account) return of(createDefaultDiffPrefs());
+ return from(this.restApiService.getDiffPreferences());
+ })
+ )
+ .subscribe((diffPrefs?: DiffPreferencesInfoAPI) => {
+ this.setDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
+ }),
+ this.account$
+ .pipe(
+ switchMap(account => {
+ if (!account) return of(undefined);
+ return from(this.restApiService.getAccountCapabilities());
+ })
+ )
+ .subscribe((capabilities?: AccountCapabilityInfo) => {
+ this.setCapabilities(capabilities);
+ }),
+ ];
+ }
+
+ finalize() {
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
+ }
+
+ updatePreferences(prefs: Partial<PreferencesInfo>) {
+ this.restApiService
+ .savePreferences(prefs)
+ .then((newPrefs: PreferencesInfo | undefined) => {
+ if (!newPrefs) return;
+ this.setPreferences(newPrefs);
+ });
+ }
+
+ updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
+ return this.restApiService
+ .saveDiffPreferences(diffPrefs)
+ .then((response: Response) => {
+ this.restApiService.getResponseObject(response).then(obj => {
+ const newPrefs = obj as unknown as DiffPreferencesInfo;
+ if (!newPrefs) return;
+ this.setDiffPreferences(newPrefs);
+ });
+ });
+ }
+
+ getDiffPreferences() {
+ return this.restApiService.getDiffPreferences().then(prefs => {
+ if (!prefs) return;
+ this.setDiffPreferences(prefs);
+ });
+ }
+
+ setPreferences(preferences: PreferencesInfo) {
+ const current = this.privateState$.getValue();
+ this.privateState$.next({...current, preferences});
+ }
+
+ setDiffPreferences(diffPreferences: DiffPreferencesInfo) {
+ const current = this.privateState$.getValue();
+ this.privateState$.next({...current, diffPreferences});
+ }
+
+ setCapabilities(capabilities?: AccountCapabilityInfo) {
+ const current = this.privateState$.getValue();
+ this.privateState$.next({...current, capabilities});
+ }
+
+ private setAccount(account?: AccountDetailInfo) {
+ const current = this.privateState$.getValue();
+ this.privateState$.next({...current, account});
+ }
}
-
-export function _testOnly_setState(state: UserState) {
- 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 userState$: Observable<UserState> = privateState$;
-
-export function updateAccount(account?: AccountDetailInfo) {
- const current = privateState$.getValue();
- privateState$.next({...current, account});
-}
-
-export function updatePreferences(preferences: PreferencesInfo) {
- const current = privateState$.getValue();
- privateState$.next({...current, preferences});
-}
-
-export function updateDiffPreferences(diffPreferences: DiffPreferencesInfo) {
- const current = privateState$.getValue();
- privateState$.next({...current, diffPreferences});
-}
-
-export const account$ = userState$.pipe(
- map(userState => userState.account),
- distinctUntilChanged()
-);
-
-/** Note that this may still be true, even if credentials have expired. */
-export const loggedIn$ = account$.pipe(
- map(account => !!account),
- distinctUntilChanged()
-);
-
-export const preferences$ = userState$.pipe(
- map(userState => userState.preferences),
- distinctUntilChanged()
-);
-
-export const diffPreferences$ = userState$.pipe(
- map(userState => userState.diffPreferences),
- distinctUntilChanged()
-);
-
-export const preferenceDiffViewMode$ = preferences$.pipe(
- map(preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE),
- distinctUntilChanged()
-);
-
-export const myTopMenuItems$ = preferences$.pipe(
- map(preferences => preferences?.my ?? []),
- distinctUntilChanged()
-);
-
-export const sizeBarInChangeTable$ = preferences$.pipe(
- map(prefs => !!prefs?.size_bar_in_change_table),
- distinctUntilChanged()
-);
-
-export const disableShortcuts$ = preferences$.pipe(
- map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
- distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
deleted file mode 100644
index d2bca85..0000000
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @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 {from, of, Subscription} from 'rxjs';
-import {switchMap} from 'rxjs/operators';
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {
- account$,
- updateAccount,
- updatePreferences,
- updateDiffPreferences,
-} from './user-model';
-import {
- createDefaultPreferences,
- createDefaultDiffPrefs,
-} from '../../constants/constants';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {DiffPreferencesInfo} from '../../types/diff';
-import {Finalizable} from '../registry';
-
-export class UserService implements Finalizable {
- private readonly subscriptions: Subscription[] = [];
-
- constructor(readonly restApiService: RestApiService) {
- from(this.restApiService.getAccount()).subscribe(
- (account?: AccountDetailInfo) => {
- updateAccount(account);
- }
- );
- this.subscriptions.push(
- account$
- .pipe(
- switchMap(account => {
- if (!account) return of(createDefaultPreferences());
- return from(this.restApiService.getPreferences());
- })
- )
- .subscribe((preferences?: PreferencesInfo) => {
- updatePreferences(preferences ?? createDefaultPreferences());
- })
- );
- this.subscriptions.push(
- account$
- .pipe(
- switchMap(account => {
- if (!account) return of(createDefaultDiffPrefs());
- return from(this.restApiService.getDiffPreferences());
- })
- )
- .subscribe((diffPrefs?: DiffPreferencesInfo) => {
- updateDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
- })
- );
- }
-
- finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-
- updatePreferences(prefs: Partial<PreferencesInfo>) {
- this.restApiService
- .savePreferences(prefs)
- .then((newPrefs: PreferencesInfo | undefined) => {
- if (!newPrefs) return;
- updatePreferences(newPrefs);
- });
- }
-
- updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
- return this.restApiService
- .saveDiffPreferences(diffPrefs)
- .then((response: Response) => {
- this.restApiService.getResponseObject(response).then(obj => {
- const newPrefs = obj as unknown as DiffPreferencesInfo;
- if (!newPrefs) return;
- updateDiffPreferences(newPrefs);
- });
- });
- }
-
- getDiffPreferences() {
- return this.restApiService.getDiffPreferences().then(prefs => {
- if (!prefs) return;
- updateDiffPreferences(prefs);
- });
- }
-}
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index f8fd534..94e3f69 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -21,12 +21,11 @@
import '../scripts/bundled-polymer';
import '@polymer/iron-test-helpers/iron-test-helpers';
import './test-router';
-import {
- _testOnlyInitAppContext,
- _testOnlyFinalizeAppContext,
-} from './test-app-context-init';
+import {AppContext, injectAppContext} from '../services/app-context';
+import {Finalizable} from '../services/registry';
+import {createTestAppContext} from './test-app-context-init';
import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
import {
cleanupTestUtils,
getCleanupsCount,
@@ -36,7 +35,6 @@
removeThemeStyles,
} from './test-utils';
import {safeTypesBridge} from '../utils/safe-types-util';
-import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
import {initGlobalVariables} from '../elements/gr-app-global-var-init';
import 'chai/chai';
import {chaiDomDiff} from '@open-wc/semantic-dom-diff';
@@ -46,14 +44,6 @@
} from '../scripts/polymer-resin-install';
import {_testOnly_allTasks} from '../utils/async-util';
import {cleanUpStorage} from '../services/storage/gr-storage_mock';
-import {updatePreferences} from '../services/user/user-model';
-import {createDefaultPreferences} from '../constants/constants';
-import {getAppContext} from '../services/app-context';
-import {_testOnly_resetState as resetChangeState} from '../services/change/change-model';
-import {_testOnly_resetState as resetChecksState} from '../services/checks/checks-model';
-import {_testOnly_resetState as resetCommentsState} from '../services/comments/comments-model';
-import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
-import {_testOnly_resetState as resetUserState} from '../services/user/user-model';
declare global {
interface Window {
@@ -104,6 +94,7 @@
window.fixture = fixtureImpl;
let testSetupTimestampMs = 0;
+let appContext: AppContext & Finalizable;
setup(() => {
testSetupTimestampMs = new Date().getTime();
@@ -112,19 +103,13 @@
// If the following asserts fails - then window.stub is
// overwritten by some other code.
assert.equal(getCleanupsCount(), 0);
- _testOnlyInitAppContext();
+ appContext = createTestAppContext();
+ injectAppContext(appContext);
// The following calls is nessecary to avoid influence of previously executed
// tests.
- initGlobalVariables();
- _testOnly_initGerritPluginApi();
+ initGlobalVariables(appContext);
- resetChangeState();
- resetChecksState();
- resetCommentsState();
- resetRouterState();
- resetUserState();
-
- const shortcuts = getAppContext().shortcutsService;
+ const shortcuts = appContext.shortcutsService;
assert.isTrue(shortcuts._testOnly_isEmpty());
const selection = document.getSelection();
if (selection) {
@@ -220,8 +205,7 @@
cancelAllTasks();
cleanUpStorage();
// Reset state
- updatePreferences(createDefaultPreferences());
- _testOnlyFinalizeAppContext();
+ appContext?.finalize();
const testTeardownTimestampMs = new Date().getTime();
const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 5afdeb7..3c6d4af 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -276,9 +276,6 @@
getDiff(): Promise<DiffInfo | undefined> {
throw new Error('getDiff() not implemented by RestApiMock.');
},
- getDiffChangeDetail(): Promise<ChangeInfo | undefined | null> {
- throw new Error('getDiffChangeDetail() not implemented by RestApiMock.');
- },
getDiffComments() {
// NOTE: This method can not be typed properly due to overloads.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 03c5967..5f5507b 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -18,26 +18,26 @@
// Init app context before any other imports
import {create, Registry, Finalizable} from '../services/registry';
import {assertIsDefined} from '../utils/common-util';
-import {AppContext, injectAppContext} from '../services/app-context';
+import {AppContext} from '../services/app-context';
import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
import {grRestApiMock} from './mocks/gr-rest-api_mock';
import {grStorageMock} from '../services/storage/gr-storage_mock';
import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
import {FlagsServiceImplementation} from '../services/flags/flags_impl';
import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeService} from '../services/change/change-service';
-import {ChecksService} from '../services/checks/checks-service';
+import {ChangeModel} from '../services/change/change-model';
+import {ChecksModel} from '../services/checks/checks-model';
import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {ConfigService} from '../services/config/config-service';
-import {UserService} from '../services/user/user-service';
-import {CommentsService} from '../services/comments/comments-service';
+import {UserModel} from '../services/user/user-model';
+import {CommentsModel} from '../services/comments/comments-model';
+import {RouterModel} from '../services/router/router-model';
import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
import {BrowserModel} from '../services/browser/browser-model';
+import {ConfigModel} from '../services/config/config-model';
-let appContext: (AppContext & Finalizable) | undefined;
-
-export function _testOnlyInitAppContext() {
+export function createTestAppContext(): AppContext & Finalizable {
const appRegistry: Registry<AppContext> = {
+ routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
flagsService: (_ctx: Partial<AppContext>) =>
new FlagsServiceImplementation(),
reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
@@ -47,42 +47,61 @@
return new GrAuthMock(ctx.eventEmitter);
},
restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
- changeService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new ChangeService(ctx.restApiService!);
+ changeModel: (ctx: Partial<AppContext>) => {
+ const routerModel = ctx.routerModel;
+ const restApiService = ctx.restApiService;
+ assertIsDefined(routerModel, 'routerModel');
+ assertIsDefined(restApiService, 'restApiService');
+ return new ChangeModel(routerModel, restApiService);
},
- commentsService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new CommentsService(ctx.restApiService!);
+ commentsModel: (ctx: Partial<AppContext>) => {
+ const routerModel = ctx.routerModel;
+ const changeModel = ctx.changeModel;
+ const restApiService = ctx.restApiService;
+ const reportingService = ctx.reportingService;
+ assertIsDefined(routerModel, 'routerModel');
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(restApiService, 'restApiService');
+ assertIsDefined(reportingService, 'reportingService');
+ return new CommentsModel(
+ routerModel,
+ changeModel,
+ restApiService,
+ reportingService
+ );
},
- checksService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new ChecksService(ctx.reportingService!);
+ checksModel: (ctx: Partial<AppContext>) => {
+ const routerModel = ctx.routerModel;
+ const changeModel = ctx.changeModel;
+ const reportingService = ctx.reportingService;
+ assertIsDefined(routerModel, 'routerModel');
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(reportingService, 'reportingService');
+ return new ChecksModel(routerModel, changeModel, reportingService);
},
jsApiService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.reportingService, 'reportingService');
return new GrJsApiInterface(ctx.reportingService!);
},
storageService: (_ctx: Partial<AppContext>) => grStorageMock,
- configService: (ctx: Partial<AppContext>) => {
+ configModel: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.changeModel, 'changeModel');
assertIsDefined(ctx.restApiService, 'restApiService');
- return new ConfigService(ctx.restApiService!);
+ return new ConfigModel(ctx.changeModel!, ctx.restApiService!);
},
- userService: (ctx: Partial<AppContext>) => {
+ userModel: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.restApiService, 'restApiService');
- return new UserService(ctx.restApiService!);
+ return new UserModel(ctx.restApiService!);
},
shortcutsService: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.userModel, 'userModel');
assertIsDefined(ctx.reportingService, 'reportingService');
- return new ShortcutsService(ctx.reportingService!);
+ return new ShortcutsService(ctx.userModel!, ctx.reportingService!);
},
- browserModel: (_ctx: Partial<AppContext>) => new BrowserModel(),
+ browserModel: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.userModel, 'userModel');
+ return new BrowserModel(ctx.userModel!);
+ },
};
- appContext = create<AppContext>(appRegistry);
- injectAppContext(appContext);
-}
-
-export function _testOnlyFinalizeAppContext() {
- appContext?.finalize();
- appContext = undefined;
+ return create<AppContext>(appRegistry);
}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 87760de..da4601b 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -14,7 +14,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import {
AccountDetailInfo,
AccountId,
@@ -31,6 +30,7 @@
ChangeMessageId,
ChangeMessageInfo,
ChangeViewChangeInfo,
+ CommentInfo,
CommentLinkInfo,
CommentLinks,
CommitId,
@@ -62,6 +62,9 @@
RequirementType,
Reviewers,
RevisionInfo,
+ RobotCommentInfo,
+ RobotId,
+ RobotRunId,
SchemesInfoMap,
ServerInfo,
SubmittedTogetherInfo,
@@ -96,10 +99,9 @@
import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
import {WebLinkInfo} from '../types/diff';
import {
+ CommentThread,
createCommentThreads,
- UIComment,
- UIDraft,
- UIHuman,
+ DraftInfo,
} from '../utils/comment-util';
import {GerritView} from '../services/router/router-model';
import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
@@ -504,7 +506,9 @@
};
}
-export function createComment(): UIHuman {
+export function createComment(
+ extra: Partial<CommentInfo | DraftInfo> = {}
+): CommentInfo {
return {
patch_set: 1 as PatchSetNum,
id: '12345' as UrlEncodedCommentId,
@@ -514,15 +518,28 @@
updated: '2018-02-13 22:48:48.018000000' as Timestamp,
unresolved: false,
path: 'abc.txt',
+ ...extra,
};
}
-export function createDraft(): UIDraft {
+export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
return {
...createComment(),
- collapsed: false,
__draft: true,
- __editing: false,
+ ...extra,
+ };
+}
+
+export function createRobotComment(
+ extra: Partial<CommentInfo> = {}
+): RobotCommentInfo {
+ return {
+ ...createComment(),
+ robot_id: 'robot-id-123' as RobotId,
+ robot_run_id: 'robot-run-id-456' as RobotRunId,
+ properties: {},
+ fix_suggestions: [],
+ ...extra,
};
}
@@ -629,14 +646,27 @@
return new ChangeComments(comments, {}, drafts, {}, {});
}
-export function createCommentThread(comments: UIComment[]) {
+export function createThread(
+ ...comments: Partial<CommentInfo | DraftInfo>[]
+): CommentThread {
+ return {
+ comments: comments.map(c => createComment(c)),
+ rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
+ path: 'test-path-comment-thread',
+ commentSide: CommentSide.REVISION,
+ patchNum: 1 as PatchSetNum,
+ line: 314,
+ };
+}
+
+export function createCommentThread(comments: Array<Partial<CommentInfo>>) {
if (!comments.length) {
throw new Error('comment is required to create a thread');
}
- comments = comments.map(comment => {
+ const filledComments = comments.map(comment => {
return {...createComment(), ...comment};
});
- const threads = createCommentThreads(comments);
+ const threads = createCommentThreads(filledComments);
return threads[0];
}
@@ -711,6 +741,7 @@
name: 'Verified',
status: SubmitRequirementStatus.SATISFIED,
submittability_expression_result: createSubmitRequirementExpressionInfo(),
+ is_legacy: false,
};
}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 2b57e98..ebeb1db 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -23,8 +23,8 @@
import {StorageService} from '../services/storage/gr-storage';
import {AuthService} from '../services/gr-auth/gr-auth';
import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {CommentsService} from '../services/comments/comments-service';
-import {UserService} from '../services/user/user-service';
+import {CommentsModel} from '../services/comments/comments-model';
+import {UserModel} from '../services/user/user-model';
import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
import {queryAndAssert, query} from '../utils/common-util';
import {FlagsService} from '../services/flags/flags';
@@ -112,12 +112,12 @@
return sinon.spy(getAppContext().restApiService, method);
}
-export function stubComments<K extends keyof CommentsService>(method: K) {
- return sinon.stub(getAppContext().commentsService, method);
+export function stubComments<K extends keyof CommentsModel>(method: K) {
+ return sinon.stub(getAppContext().commentsModel, method);
}
-export function stubUsers<K extends keyof UserService>(method: K) {
- return sinon.stub(getAppContext().userService, method);
+export function stubUsers<K extends keyof UserModel>(method: K) {
+ return sinon.stub(getAppContext().userModel, method);
}
export function stubShortcuts<K extends keyof ShortcutsService>(method: K) {
@@ -192,13 +192,14 @@
const start = Date.now();
let sleep = 0;
if (predicate()) return Promise.resolve();
+ const error = new Error(message);
return new Promise((resolve, reject) => {
const waiter = () => {
if (predicate()) {
return resolve();
}
if (Date.now() - start >= 1000) {
- return reject(new Error(message));
+ return reject(error);
}
setTimeout(waiter, sleep);
sleep = sleep === 0 ? 1 : sleep * 4;
@@ -218,21 +219,33 @@
* await listenOnce(el, 'render');
* ...
*/
-export function listenOnce(el: EventTarget, eventType: string) {
- return new Promise<void>(resolve => {
- const listener = () => {
+export function listenOnce<T extends Event>(
+ el: EventTarget,
+ eventType: string
+) {
+ return new Promise<T>(resolve => {
+ const listener = (e: Event) => {
removeEventListener();
- resolve();
+ resolve(e as T);
};
- el.addEventListener(eventType, listener);
let removeEventListener = () => {
el.removeEventListener(eventType, listener);
removeEventListener = () => {};
};
+ el.addEventListener(eventType, listener);
registerTestCleanup(removeEventListener);
});
}
+export function dispatch<T>(element: HTMLElement, type: string, detail: T) {
+ const eventOptions = {
+ detail,
+ bubbles: true,
+ composed: true,
+ };
+ element.dispatchEvent(new CustomEvent<T>(type, eventOptions));
+}
+
export function pressKey(
element: HTMLElement,
key: string | Key,
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index fc38756..f58abb3 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -692,9 +692,10 @@
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
*/
export interface CommentInfo {
- // TODO(TS): Make this required.
- patch_set?: PatchSetNum;
id: UrlEncodedCommentId;
+ updated: Timestamp;
+ // TODO(TS): Make this required. Every comment must have patch_set set.
+ patch_set?: PatchSetNum;
path?: string;
side?: CommentSide;
parent?: number;
@@ -702,7 +703,6 @@
range?: CommentRange;
in_reply_to?: UrlEncodedCommentId;
message?: string;
- updated: Timestamp;
author?: AccountInfo;
tag?: string;
unresolved?: boolean;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index f467cf6..4f24535 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import {PatchSetNum} from './common';
-import {UIComment} from '../utils/comment-util';
+import {Comment} from '../utils/comment-util';
import {FetchRequest} from './types';
import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
import {Category, RunStatus} from '../api/checks';
@@ -168,7 +168,7 @@
export interface OpenFixPreviewEventDetail {
patchNum?: PatchSetNum;
- comment?: UIComment;
+ comment?: Comment;
}
export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
@@ -178,7 +178,7 @@
export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
export interface CreateFixCommentEventDetail {
patchNum?: PatchSetNum;
- comment?: UIComment;
+ comment?: Comment;
}
export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
@@ -249,3 +249,12 @@
title: string;
}
export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+/**
+ * This event can be used for Polymer properties that have `notify: true` set.
+ * But it is also generally recommended when you want to notify your parent
+ * elements about a property update, also for Lit elements.
+ *
+ * The name of the event should be `prop-name-changed`.
+ */
+export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index b90b12f..42f3d45 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -186,7 +186,6 @@
patchRange: PatchRange | null;
selectedFileIndex: number;
showReplyDialog: boolean;
- showDownloadDialog: boolean;
diffMode: DiffViewMode | null;
numFilesShown: number | null;
}
@@ -198,7 +197,6 @@
selectedFileIndex?: number;
selectedChangeIndex?: number;
showReplyDialog?: boolean;
- showDownloadDialog?: boolean;
diffMode?: DiffViewMode;
numFilesShown?: number;
scrollTop?: number;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5b08fab..966a75c 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -30,84 +30,116 @@
AccountInfo,
AccountDetailInfo,
} from '../types/common';
-import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
+import {CommentSide, SpecialFilePath} from '../constants/constants';
import {parseDate} from './date-util';
-import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
import {isMergeParent, getParentIndex} from './patch-set-util';
import {DiffInfo} from '../types/diff';
+import {LineNumber} from '../api/diff';
export interface DraftCommentProps {
- __draft?: boolean;
- __draftID?: string;
- __date?: Date;
+ // This must be true for all drafts. Drafts received from the backend will be
+ // modified immediately with __draft:true before allowing them to get into
+ // the application state.
+ __draft: boolean;
}
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
- collapsed?: boolean;
+export interface UnsavedCommentProps {
+ // This must be true for all unsaved comment drafts. An unsaved draft is
+ // always just local to a comment component like <gr-comment> or
+ // <gr-comment-thread>. Unsaved drafts will never appear in the application
+ // state.
+ __unsaved: boolean;
}
-export interface UIStateDraftProps {
- __editing?: boolean;
-}
+export type DraftInfo = CommentInfo & DraftCommentProps;
-export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
+export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
+export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
export type CommentMap = {[path: string]: boolean};
-export function isRobot<T extends CommentInfo>(
+export function isRobot<T extends CommentBasics>(
x: T | DraftInfo | RobotCommentInfo | undefined
): x is RobotCommentInfo {
return !!x && !!(x as RobotCommentInfo).robot_id;
}
-export function isDraft<T extends CommentInfo>(
- x: T | UIDraft | undefined
-): x is UIDraft {
- return !!x && !!(x as UIDraft).__draft;
+export function isDraft<T extends CommentBasics>(
+ x: T | DraftInfo | undefined
+): x is DraftInfo {
+ return !!x && !!(x as DraftInfo).__draft;
+}
+
+export function isUnsaved<T extends CommentBasics>(
+ x: T | UnsavedInfo | undefined
+): x is UnsavedInfo {
+ return !!x && !!(x as UnsavedInfo).__unsaved;
+}
+
+export function isDraftOrUnsaved<T extends CommentBasics>(
+ x: T | DraftInfo | UnsavedInfo | undefined
+): x is UnsavedInfo | DraftInfo {
+ return isDraft(x) || isUnsaved(x);
}
interface SortableComment {
- __draft?: boolean;
- __date?: Date;
- updated?: Timestamp;
- id?: UrlEncodedCommentId;
+ updated: Timestamp;
+ id: UrlEncodedCommentId;
}
export function sortComments<T extends SortableComment>(comments: T[]): T[] {
return comments.slice(0).sort((c1, c2) => {
- const d1 = !!c1.__draft;
- const d2 = !!c2.__draft;
+ const d1 = isDraft(c1);
+ const d2 = isDraft(c2);
if (d1 !== d2) return d1 ? 1 : -1;
- const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
- const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+ const date1 = parseDate(c1.updated);
+ const date2 = parseDate(c2.updated);
const dateDiff = date1!.valueOf() - date2!.valueOf();
if (dateDiff !== 0) return dateDiff;
- const id1 = c1.id ?? '';
- const id2 = c2.id ?? '';
+ const id1 = c1.id;
+ const id2 = c2.id;
return id1.localeCompare(id2);
});
}
-export function createCommentThreads(
- comments: UIComment[],
- patchRange?: PatchRange
-) {
+export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+ return {
+ path: thread.path,
+ patch_set: thread.patchNum,
+ side: thread.commentSide ?? CommentSide.REVISION,
+ line: typeof thread.line === 'number' ? thread.line : undefined,
+ range: thread.range,
+ parent: thread.mergeParentNum,
+ message: '',
+ unresolved: true,
+ __unsaved: true,
+ };
+}
+
+export function createUnsavedReply(
+ replyingTo: CommentInfo,
+ message: string,
+ unresolved: boolean
+): UnsavedInfo {
+ return {
+ path: replyingTo.path,
+ patch_set: replyingTo.patch_set,
+ side: replyingTo.side,
+ line: replyingTo.line,
+ range: replyingTo.range,
+ parent: replyingTo.parent,
+ in_reply_to: replyingTo.id,
+ message,
+ unresolved,
+ __unsaved: true,
+ };
+}
+
+export function createCommentThreads(comments: CommentInfo[]) {
const sortedComments = sortComments(comments);
const threads: CommentThread[] = [];
const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -129,7 +161,6 @@
const newThread: CommentThread = {
comments: [comment],
patchNum: comment.patch_set,
- diffSide: Side.LEFT,
commentSide: comment.side ?? CommentSide.REVISION,
mergeParentNum: comment.parent,
path: comment.path,
@@ -137,13 +168,6 @@
range: comment.range,
rootId: comment.id,
};
- if (patchRange) {
- if (isInBaseOfPatchRange(comment, patchRange))
- newThread.diffSide = Side.LEFT;
- else if (isInRevisionOfPatchRange(comment, patchRange))
- newThread.diffSide = Side.RIGHT;
- else throw new Error('comment does not belong in given patchrange');
- }
if (!comment.line && !comment.range) {
newThread.line = 'FILE';
}
@@ -154,68 +178,98 @@
}
export interface CommentThread {
- comments: UIComment[];
+ /**
+ * This can only contain at most one draft. And if so, then it is the last
+ * comment in this list. This must not contain unsaved drafts.
+ */
+ comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
+ /**
+ * Identical to the id of the first comment. If this is undefined, then the
+ * thread only contains an unsaved draft.
+ */
+ rootId?: UrlEncodedCommentId;
path: string;
commentSide: CommentSide;
/* mergeParentNum is the merge parent number only valid for merge commits
when commentSide is PARENT.
mergeParentNum is undefined for auto merge commits
+ Same as `parent` in CommentInfo.
*/
mergeParentNum?: number;
patchNum?: PatchSetNum;
+ /* Different from CommentInfo, which just keeps the line undefined for
+ FILE comments. */
line?: LineNumber;
- /* rootId is optional since we create a empty comment thread element for
- drafts and then create the draft which becomes the root */
- rootId?: UrlEncodedCommentId;
- diffSide?: Side;
range?: CommentRange;
ported?: boolean; // is the comment ported over from a previous patchset
rangeInfoLost?: boolean; // if BE was unable to determine a range for this
}
-export function getLastComment(thread?: CommentThread): UIComment | undefined {
- const len = thread?.comments.length;
- return thread && len ? thread.comments[len - 1] : undefined;
+export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+ const len = thread.comments.length;
+ return thread.comments[len - 1];
}
-export function getFirstComment(thread?: CommentThread): UIComment | undefined {
- return thread?.comments?.[0];
+export function getLastPublishedComment(
+ thread: CommentThread
+): CommentInfo | undefined {
+ const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+ const len = publishedComments.length;
+ return publishedComments[len - 1];
}
-export function countComments(thread?: CommentThread) {
- return thread?.comments?.length ?? 0;
+export function getFirstComment(
+ thread: CommentThread
+): CommentInfo | undefined {
+ return thread.comments[0];
}
-export function isPatchsetLevel(thread?: CommentThread): boolean {
- return thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+export function countComments(thread: CommentThread) {
+ return thread.comments.length;
}
-export function isUnresolved(thread?: CommentThread): boolean {
+export function isPatchsetLevel(thread: CommentThread): boolean {
+ return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function isUnresolved(thread: CommentThread): boolean {
return !isResolved(thread);
}
-export function isResolved(thread?: CommentThread): boolean {
- return !getLastComment(thread)?.unresolved;
+export function isResolved(thread: CommentThread): boolean {
+ const lastUnresolved = getLastComment(thread)?.unresolved;
+ return !lastUnresolved ?? false;
}
-export function isDraftThread(thread?: CommentThread): boolean {
+export function isDraftThread(thread: CommentThread): boolean {
return isDraft(getLastComment(thread));
}
-export function isRobotThread(thread?: CommentThread): boolean {
+export function isRobotThread(thread: CommentThread): boolean {
return isRobot(getFirstComment(thread));
}
-export function hasHumanReply(thread?: CommentThread): boolean {
+export function hasHumanReply(thread: CommentThread): boolean {
return countComments(thread) > 1 && !isRobot(getLastComment(thread));
}
+export function lastUpdated(thread: CommentThread): Date | undefined {
+ // We don't want to re-sort comments when you save a draft reply, so
+ // we stick to the timestampe of the last *published* comment.
+ const lastUpdated =
+ getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated;
+ return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined;
+}
/**
* Whether the given comment should be included in the base side of the
* given patch range.
*/
export function isInBaseOfPatchRange(
- comment: CommentBasics,
+ comment: {
+ patch_set?: PatchSetNum;
+ side?: CommentSide;
+ parent?: number;
+ },
range: PatchRange
) {
// If the base of the patch range is a parent of a merge, and the comment
@@ -249,7 +303,10 @@
* given patch range.
*/
export function isInRevisionOfPatchRange(
- comment: CommentBasics,
+ comment: {
+ patch_set?: PatchSetNum;
+ side?: CommentSide;
+ },
range: PatchRange
) {
return (
@@ -271,7 +328,7 @@
}
export function getPatchRangeForCommentUrl(
- comment: UIComment,
+ comment: Comment,
latestPatchNum: RevisionPatchSetNum
) {
if (!comment.patch_set) throw new Error('Missing comment.patch_set');
@@ -279,7 +336,7 @@
// TODO(dhruvsri): Add handling for comment left on parents of merge commits
if (comment.side === CommentSide.PARENT) {
if (comment.patch_set === ParentPatchSetNum)
- throw new Error('diffSide cannot be PARENT');
+ throw new Error('comment.patch_set cannot be PARENT');
return {
patchNum: comment.patch_set as RevisionPatchSetNum,
basePatchNum: ParentPatchSetNum,
@@ -355,30 +412,46 @@
return authors;
}
-export function computeId(comment: UIComment) {
- if (comment.id) return comment.id;
- if (isDraft(comment)) return comment.__draftID;
- throw new Error('Missing id in root comment.');
-}
-
/**
- * Add path info to every comment as CommentInfo returned
- * from server does not have that.
- *
- * TODO(taoalpha): should consider changing BE to send path
- * back within CommentInfo
+ * Add path info to every comment as CommentInfo returned from server does not
+ * have that.
*/
export function addPath<T>(comments: {[path: string]: T[]} = {}): {
[path: string]: Array<T & {path: string}>;
} {
const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
for (const filePath of Object.keys(comments)) {
- const allCommentsForPath = comments[filePath] || [];
- if (allCommentsForPath.length) {
- updatedComments[filePath] = allCommentsForPath.map(comment => {
- return {...comment, path: filePath};
- });
- }
+ updatedComments[filePath] = (comments[filePath] || []).map(comment => {
+ return {...comment, path: filePath};
+ });
}
return updatedComments;
}
+
+/**
+ * Add __draft:true to all drafts returned from server so that they can be told
+ * apart from published comments easily.
+ */
+export function addDraftProp(
+ draftsByPath: {[path: string]: CommentInfo[]} = {}
+) {
+ const updated: {[path: string]: DraftInfo[]} = {};
+ for (const filePath of Object.keys(draftsByPath)) {
+ updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
+ return {...draft, __draft: true};
+ });
+ }
+ return updated;
+}
+
+export function reportingDetails(comment: CommentBasics) {
+ return {
+ id: comment?.id,
+ message_length: comment?.message?.trim().length,
+ in_reply_to: comment?.in_reply_to,
+ unresolved: comment?.unresolved,
+ path_length: comment?.path?.length,
+ line: comment?.range?.start_line ?? comment?.line,
+ unsaved: isUnsaved(comment),
+ };
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 3c8f26d..f5a2177 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -23,9 +23,8 @@
sortComments,
} from './comment-util';
import {createComment, createCommentThread} from '../test/test-data-generators';
-import {CommentSide, Side} from '../constants/constants';
+import {CommentSide} from '../constants/constants';
import {
- BasePatchSetNum,
ParentPatchSetNum,
PatchSetNum,
RevisionPatchSetNum,
@@ -37,7 +36,6 @@
test('isUnresolved', () => {
const thread = createCommentThread([createComment()]);
- assert.isFalse(isUnresolved(undefined));
assert.isFalse(isUnresolved(thread));
assert.isTrue(
@@ -97,7 +95,6 @@
{
id: 'new_draft' as UrlEncodedCommentId,
message: 'i do not like either of you',
- diffSide: Side.LEFT,
__draft: true,
updated: '2015-12-20 15:01:20.396000000' as Timestamp,
},
@@ -106,13 +103,11 @@
message: 'i like you, jack',
updated: '2015-12-23 15:00:20.396000000' as Timestamp,
line: 1,
- diffSide: Side.LEFT,
},
{
id: 'jacks_reply' as UrlEncodedCommentId,
message: 'i like you, too',
updated: '2015-12-24 15:01:20.396000000' as Timestamp,
- diffSide: Side.LEFT,
line: 1,
in_reply_to: 'sallys_confession',
},
@@ -153,21 +148,16 @@
},
];
- const actualThreads = createCommentThreads(comments, {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 4 as RevisionPatchSetNum,
- });
+ const actualThreads = createCommentThreads(comments);
assert.equal(actualThreads.length, 2);
- assert.equal(actualThreads[0].diffSide, Side.LEFT);
assert.equal(actualThreads[0].comments.length, 2);
assert.deepEqual(actualThreads[0].comments[0], comments[0]);
assert.deepEqual(actualThreads[0].comments[1], comments[1]);
assert.equal(actualThreads[0].patchNum, 1 as PatchSetNum);
assert.equal(actualThreads[0].line, 1);
- assert.equal(actualThreads[1].diffSide, Side.LEFT);
assert.equal(actualThreads[1].comments.length, 1);
assert.deepEqual(actualThreads[1].comments[0], comments[2]);
assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
@@ -194,7 +184,6 @@
const expectedThreads = [
{
- diffSide: Side.LEFT,
commentSide: CommentSide.REVISION,
path: '/p',
rootId: 'betsys_confession' as UrlEncodedCommentId,
@@ -226,13 +215,7 @@
},
];
- assert.deepEqual(
- createCommentThreads(comments, {
- basePatchNum: 5 as BasePatchSetNum,
- patchNum: 10 as RevisionPatchSetNum,
- }),
- expectedThreads
- );
+ assert.deepEqual(createCommentThreads(comments), expectedThreads);
});
test('does not thread unrelated comments at same location', () => {
@@ -241,14 +224,12 @@
id: 'sallys_confession' as UrlEncodedCommentId,
message: 'i like you, jack',
updated: '2015-12-23 15:00:20.396000000' as Timestamp,
- diffSide: Side.LEFT,
path: '/p',
},
{
id: 'jacks_reply' as UrlEncodedCommentId,
message: 'i like you, too',
updated: '2015-12-24 15:01:20.396000000' as Timestamp,
- diffSide: Side.LEFT,
path: '/p',
},
];
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index bd0f742..b96ebe6 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -390,29 +390,51 @@
return true;
}
-export function addGlobalShortcut(
- shortcut: Binding,
- listener: (e: KeyboardEvent) => void
-) {
- return addShortcut(document.body, shortcut, listener);
+export interface ShortcutOptions {
+ /**
+ * Do you want to suppress events from <input> elements and such?
+ */
+ shouldSuppress?: boolean;
+ /**
+ * Do you want to take care of calling preventDefault() and
+ * stopPropagation() yourself?
+ */
+ doNotPrevent?: boolean;
}
+export function addGlobalShortcut(
+ shortcut: Binding,
+ listener: (e: KeyboardEvent) => void,
+ options: ShortcutOptions = {
+ shouldSuppress: true,
+ doNotPrevent: false,
+ }
+) {
+ return addShortcut(document.body, shortcut, listener, options);
+}
+
+/**
+ * Deprecated.
+ *
+ * For LitElement use the shortcut-controller.
+ * For PolymerElement use the keyboard-shortcut-mixin.
+ */
export function addShortcut(
element: HTMLElement,
shortcut: Binding,
listener: (e: KeyboardEvent) => void,
- options: {
- shouldSuppress: boolean;
- } = {
+ options: ShortcutOptions = {
shouldSuppress: false,
+ doNotPrevent: false,
}
) {
const wrappedListener = (e: KeyboardEvent) => {
if (e.repeat && !shortcut.allowRepeat) return;
if (options.shouldSuppress && shouldSuppress(e)) return;
- if (eventMatchesShortcut(e, shortcut)) {
- listener(e);
- }
+ if (!eventMatchesShortcut(e, shortcut)) return;
+ if (!options.doNotPrevent) e.preventDefault();
+ if (!options.doNotPrevent) e.stopPropagation();
+ listener(e);
};
element.addEventListener('keydown', wrappedListener);
return () => element.removeEventListener('keydown', wrappedListener);
@@ -454,7 +476,10 @@
// Suppress shortcuts if the key is 'enter'
// and target is an anchor or button or paper-tab.
(e.keyCode === 13 &&
- (tagName === 'A' || tagName === 'BUTTON' || tagName === 'PAPER-TAB'))
+ (tagName === 'A' ||
+ tagName === 'BUTTON' ||
+ tagName === 'GR-BUTTON' ||
+ tagName === 'PAPER-TAB'))
) {
return true;
}
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index e139805..28157d9 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -326,6 +326,15 @@
});
});
+ test('suppress "enter" shortcut event from <gr-button>', async () => {
+ await keyEventOn(
+ document.createElement('gr-button'),
+ e => assert.isTrue(shouldSuppress(e)),
+ 13,
+ 'enter'
+ );
+ });
+
test('suppress "enter" shortcut event from <a>', async () => {
await keyEventOn(document.createElement('a'), e => {
assert.isFalse(shouldSuppress(e));
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 0aa4a51..c154958 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -20,6 +20,7 @@
SubmitRequirementResultInfo,
SubmitRequirementStatus,
} from '../api/rest-api';
+import {FlagsService, KnownExperimentId} from '../services/flags/flags';
import {
AccountInfo,
ApprovalInfo,
@@ -224,12 +225,16 @@
}
export function extractAssociatedLabels(
- requirement: SubmitRequirementResultInfo
+ requirement: SubmitRequirementResultInfo,
+ type: 'all' | 'onlyOverride' | 'onlySubmittability' = 'all'
): string[] {
- let labels = extractLabelsFrom(
- requirement.submittability_expression_result.expression
- );
- if (requirement.override_expression_result) {
+ let labels: string[] = [];
+ if (type !== 'onlyOverride') {
+ labels = labels.concat(
+ extractLabelsFrom(requirement.submittability_expression_result.expression)
+ );
+ }
+ if (requirement.override_expression_result && type !== 'onlySubmittability') {
labels = labels.concat(
extractLabelsFrom(requirement.override_expression_result.expression)
);
@@ -258,20 +263,9 @@
* If there is at least one non-legacy requirement, filter legacy requirements.
*/
export function getRequirements(change?: ParsedChangeInfo | ChangeInfo) {
- let submit_requirements = (change?.submit_requirements ?? []).filter(
- req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
- );
-
- const hasNonLegacyRequirements = submit_requirements.some(
- req => req.is_legacy === false
- );
- if (hasNonLegacyRequirements) {
- submit_requirements = submit_requirements.filter(
- req => req.is_legacy === false
- );
- }
-
- return submit_requirements;
+ return (change?.submit_requirements ?? [])
+ .filter(req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE)
+ .filter(req => req.is_legacy === false);
}
// TODO(milutin): This may be temporary for demo purposes
@@ -305,3 +299,16 @@
label => !labelAssociatedWithSubmitReqs.includes(label)
);
}
+
+export function showNewSubmitRequirements(
+ flagsService: FlagsService,
+ change?: ParsedChangeInfo | ChangeInfo
+) {
+ const isSubmitRequirementsUiEnabled = flagsService.isEnabled(
+ KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+ );
+ if (!isSubmitRequirementsUiEnabled) return false;
+ if ((getRequirements(change) ?? []).length === 0) return false;
+
+ return true;
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index 6cb04af..1b8ad0c 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -298,7 +298,7 @@
is_legacy: true,
};
const change = createChangeInfoWith([requirement]);
- assert.deepEqual(getRequirements(change), [requirement]);
+ assert.deepEqual(getRequirements(change), []);
});
test('legacy and non-legacy - filter legacy', () => {
const requirement = {
@@ -313,10 +313,7 @@
assert.deepEqual(getRequirements(change), [requirement2]);
});
test('filter not applicable', () => {
- const requirement = {
- ...createSubmitRequirementResultInfo(),
- is_legacy: true,
- };
+ const requirement = createSubmitRequirementResultInfo();
const requirement2 = {
...createSubmitRequirementResultInfo(),
status: SubmitRequirementStatus.NOT_APPLICABLE,
@@ -348,6 +345,7 @@
...createSubmitRequirementExpressionInfo(),
expression: `label:${triggerVote}=MAX`,
},
+ is_legacy: false,
},
],
labels: {
diff --git a/polygerrit-ui/app/utils/math-util.ts b/polygerrit-ui/app/utils/math-util.ts
new file mode 100644
index 0000000..adec7d3
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @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.
+ */
+
+/**
+ * Returns a random integer between `from` and `to`, both included.
+ * So getRandomInt(0, 2) returns 0, 1, or 2 each with probability 1/3.
+ */
+export function getRandomInt(from: number, to: number) {
+ return Math.floor(Math.random() * (to + 1 - from) + from);
+}
diff --git a/polygerrit-ui/app/utils/math-util_test.ts b/polygerrit-ui/app/utils/math-util_test.ts
new file mode 100644
index 0000000..fca1d73
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util_test.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 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 {getRandomInt} from './math-util';
+
+suite('math-util tests', () => {
+ test('getRandomInt', () => {
+ let r = 0;
+ const randomStub = sinon.stub(Math, 'random').callsFake(() => r);
+
+ assert.equal(getRandomInt(0, 0), 0);
+ assert.equal(getRandomInt(0, 2), 0);
+ assert.equal(getRandomInt(0, 100), 0);
+ assert.equal(getRandomInt(10, 10), 10);
+ assert.equal(getRandomInt(10, 12), 10);
+ assert.equal(getRandomInt(10, 100), 10);
+
+ r = 0.999;
+ assert.equal(getRandomInt(0, 0), 0);
+ assert.equal(getRandomInt(0, 2), 2);
+ assert.equal(getRandomInt(0, 100), 100);
+ assert.equal(getRandomInt(10, 10), 10);
+ assert.equal(getRandomInt(10, 12), 12);
+ assert.equal(getRandomInt(10, 100), 100);
+
+ r = 0.5;
+ assert.equal(getRandomInt(0, 0), 0);
+ assert.equal(getRandomInt(0, 2), 1);
+ assert.equal(getRandomInt(0, 100), 50);
+ assert.equal(getRandomInt(10, 10), 10);
+ assert.equal(getRandomInt(10, 12), 11);
+ assert.equal(getRandomInt(10, 100), 55);
+
+ r = 0.0;
+ assert.equal(getRandomInt(0, 2), 0);
+ r = 0.33;
+ assert.equal(getRandomInt(0, 2), 0);
+ r = 0.34;
+ assert.equal(getRandomInt(0, 2), 1);
+ r = 0.66;
+ assert.equal(getRandomInt(0, 2), 1);
+ r = 0.67;
+ assert.equal(getRandomInt(0, 2), 2);
+ r = 0.99;
+ assert.equal(getRandomInt(0, 2), 2);
+
+ randomStub.restore();
+ });
+});
diff --git a/polygerrit-ui/app/utils/observable-util.ts b/polygerrit-ui/app/utils/observable-util.ts
new file mode 100644
index 0000000..e39aa48
--- /dev/null
+++ b/polygerrit-ui/app/utils/observable-util.ts
@@ -0,0 +1,27 @@
+/**
+ * @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 {Observable} from 'rxjs';
+import {distinctUntilChanged, map, shareReplay} from 'rxjs/operators';
+import {deepEqual} from './deep-util';
+
+export function select<A, B>(obs$: Observable<A>, mapper: (_: A) => B) {
+ return obs$.pipe(
+ map(mapper),
+ distinctUntilChanged(deepEqual),
+ shareReplay(1)
+ );
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 11717fb..c7b3d9e 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -29,7 +29,6 @@
{@param? useGoogleFonts: ?}
{@param? changeRequestsPath: ?}
{@param? defaultChangeDetailHex: ?}
- {@param? defaultDiffDetailHex: ?}
{@param? defaultDashboardHex: ?}
{@param? dashboardQuery: ?}
{@param? userIsAuthenticated: ?}
@@ -52,9 +51,6 @@
{if $defaultChangeDetailHex}
changePage: '{$defaultChangeDetailHex}',
{/if}
- {if $defaultDiffDetailHex}
- diffPage: '{$defaultDiffDetailHex}',
- {/if}
{if $defaultDashboardHex}
dashboardPage: '{$defaultDashboardHex}',
{/if}
@@ -99,18 +95,11 @@
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{/if}
{/if}
- {if $defaultDiffDetailHex}
- <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultDiffDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
- {if $userIsAuthenticated}
- <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
- {/if}
- <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script"/>
- {/if}
- <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+ <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script" crossorigin="anonymous"/>
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/robotcomments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{if $userIsAuthenticated}
- <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+ <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{/if}
{/if}
{if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
diff --git a/tools/BUILD b/tools/BUILD
index 2726132..e7a5230 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -102,7 +102,7 @@
"-Xep:AsyncFunctionReturnsNull:ERROR",
"-Xep:AutoValueConstructorOrderChecker:ERROR",
"-Xep:AutoValueFinalMethods:ERROR",
- # "-Xep:AutoValueImmutableFields:WARN",
+ "-Xep:AutoValueImmutableFields:ERROR",
# "-Xep:AutoValueSubclassLeaked:WARN",
"-Xep:BadAnnotationImplementation:ERROR",
"-Xep:BadComparable:ERROR",
@@ -119,7 +119,7 @@
"-Xep:CacheLoaderNull:ERROR",
"-Xep:CannotMockFinalClass:ERROR",
"-Xep:CanonicalDuration:ERROR",
- # "-Xep:CatchAndPrintStackTrace:WARN",
+ "-Xep:CatchAndPrintStackTrace:ERROR",
"-Xep:CatchFail:ERROR",
"-Xep:ChainedAssertionLosesContext:ERROR",
"-Xep:ChainingConstructorIgnoresParameter:ERROR",
@@ -151,7 +151,7 @@
"-Xep:DeadException:ERROR",
"-Xep:DeadThread:ERROR",
"-Xep:DefaultCharset:ERROR",
- # "-Xep:DefaultPackage:WARN",
+ "-Xep:DefaultPackage:ERROR",
"-Xep:DepAnn:ERROR",
"-Xep:DeprecatedVariable:ERROR",
"-Xep:DiscardedPostfixExpression:ERROR",
@@ -170,7 +170,7 @@
"-Xep:EmptyBlockTag:ERROR",
"-Xep:EmptyCatch:ERROR",
"-Xep:EmptySetMultibindingContributions:ERROR",
- # "-Xep:EqualsGetClass:WARN",
+ "-Xep:EqualsGetClass:ERROR",
"-Xep:EqualsHashCode:ERROR",
"-Xep:EqualsIncompatibleType:ERROR",
"-Xep:EqualsNaN:ERROR",
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 3695e16..dec5f67 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -25,6 +25,7 @@
@RunWith(Suite.class)
@Suite.SuiteClasses({%s})
+@SuppressWarnings("DefaultPackage")
public class %s {}
"""