Merge changes Icff51096,Iba2284a7
* changes:
Migrate stars index queries to read from All-Users repository
Migrate has:draft to read changes from All-Users repository
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 3977278..cc8d813 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -136,6 +136,19 @@
+
Changes originally submitted by a user in 'GROUP'.
+[[uploader]]
+uploader:'USER'::
++
+Changes where the latest patch set was uploaded by 'USER'.
+The special case of `uploader:self` will find changes uploaded
+by the caller.
+
+[[uploaderin]]
+uploaderin:'GROUP'::
++
+Changes where the latest patch set was uploaded by a user in
+'GROUP'.
+
[[query]]
query:'[name=]NAME[,user=USER]'::
+
@@ -466,6 +479,12 @@
True on any change where the current user is the change owner.
Same as `owner:self`.
+is:uploader::
++
+True on any change where the current user is the uploader of
+the latest patch set.
+Same as `uploader:self`.
+
is:reviewer::
+
True on any change where the current user is a reviewer.
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index 900b2e2..9e4416b 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -39,17 +39,17 @@
/**
* List leaf predicates that are fulfilled, for example the expression
*
- * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+ * <p><i>label:Code-Review=+2 and branch:refs/heads/master</i>
*
* <p>has two leaf predicates:
*
* <ul>
- * <li>label:code-review=+2
+ * <li>label:Code-Review=+2
* <li>branch:refs/heads/master
* </ul>
*
* This method will return the leaf predicates that were fulfilled, for example if only the first
- * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+ * predicate was fulfilled, the returned list will be equal to ["label:Code-Review=+2"].
*/
public abstract ImmutableList<String> passingAtoms();
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index b7fa398..13625c1 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -44,8 +44,14 @@
/**
* Whether this result was created from a legacy {@link SubmitRecord}, or by evaluating a {@link
* SubmitRequirement}.
+ *
+ * <p>If equals {@link Optional#empty()}, we treat the result as non-legacy (false).
*/
- public abstract boolean legacy();
+ public abstract Optional<Boolean> legacy();
+
+ public boolean isLegacy() {
+ return legacy().orElse(false);
+ }
@Memoized
public Status status() {
@@ -122,7 +128,7 @@
public abstract Builder patchSetCommitId(ObjectId value);
- public abstract Builder legacy(boolean value);
+ public abstract Builder legacy(Optional<Boolean> value);
public abstract SubmitRequirementResult build();
}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 85482e4..c8001bb 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -364,7 +364,7 @@
* <p>Should not be used in new code, as it doesn't result in a single atomic batch ref update for
* code and NoteDb meta refs.
*
- * @param updateRef whether to update the ref during {@code updateRepo}.
+ * @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
*/
@Deprecated
public ChangeInserter setUpdateRef(boolean updateRef) {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 9a94d93..328c5de 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -99,6 +99,8 @@
import com.google.gerrit.server.cancellation.RequestCancelledException;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
import com.google.gerrit.server.index.change.ChangeField;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -253,6 +255,7 @@
TrackingFooters trackingFooters,
Metrics metrics,
RevisionJson.Factory revisionJsonFactory,
+ ExperimentFeatures experimentFeatures,
@GerritServerConfig Config cfg,
@Assisted Iterable<ListChangesOption> options,
@Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
@@ -271,7 +274,9 @@
this.revisionJson = revisionJsonFactory.create(options);
this.options = Sets.immutableEnumSet(options);
this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
- this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
+ this.lazyLoad =
+ containsAnyOf(this.options, REQUIRE_LAZY_LOAD)
+ || lazyloadSubmitRequirements(this.options, experimentFeatures);
this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
logger.atFine().log("options = %s", options);
@@ -938,4 +943,20 @@
}
return ImmutableListMultimap.of();
}
+
+ private static boolean lazyloadSubmitRequirements(
+ Set<ListChangesOption> changeOptions, ExperimentFeatures experimentFeatures) {
+ // TODO(ghareeb,hiesel): Remove this method.
+ // We are testing the new submit requirements with users in lieu of upgrading the change index
+ // to a version that supports the new requirements.
+ // Upgrading now, before the feature is finalized would be counter productive, because the index
+ // format might change while we iterate over the feature.
+ // Allowing changes to lazyload parameters will slow down dashboards for users who have this
+ // feature enabled, but will backfill submit requirements that weren't loaded from the index by
+ // simply computing them.
+ return changeOptions.contains(SUBMIT_REQUIREMENTS)
+ && experimentFeatures.isFeatureEnabled(
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD);
+ }
}
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index ebb0790..8eeec62 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -47,7 +47,7 @@
submitRequirementExpressionToInfo(
req.submittabilityExpression(), result.submittabilityExpressionResult());
info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
- info.isLegacy = result.legacy();
+ info.isLegacy = result.isLegacy();
return info;
}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 3a7f2b2..b182deb 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -187,6 +187,7 @@
import com.google.gerrit.server.project.ProjectCacheImpl;
import com.google.gerrit.server.project.ProjectNameLockManager;
import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
import com.google.gerrit.server.project.SubmitRuleEvaluator;
import com.google.gerrit.server.query.approval.ApprovalModule;
@@ -388,6 +389,8 @@
DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
DynamicSet.setOf(binder(), UserScopedEventListener.class);
DynamicSet.setOf(binder(), CommitValidationListener.class);
+ DynamicSet.bind(binder(), CommitValidationListener.class)
+ .to(SubmitRequirementExpressionsValidator.class);
DynamicSet.setOf(binder(), CommentValidator.class);
DynamicSet.setOf(binder(), ChangeMessageModifier.class);
DynamicSet.setOf(binder(), RefOperationValidationListener.class);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index a5ef805..65f8f2d 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -25,13 +25,25 @@
public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
"GerritBackendRequestFeature__remove_revision_etag";
+ /** Enable storing submit requirements in NoteDb when the change is merged. */
+ public static final String GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE =
+ "GerritBackendRequestFeature__store_submit_requirements_on_merge";
+
/**
* Allow legacy {@link com.google.gerrit.entities.SubmitRecord}s to be converted and returned as
* submit requirements by the {@link
* com.google.gerrit.server.project.SubmitRequirementsEvaluator}.
*/
- public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS =
- "GerritBackendRequestFeature__enable_legacy_submit_requirements";
+ public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS =
+ "GerritBackendRequestFeature__enable_submit_requirements";
+
+ /**
+ * Allow SubmitRequirements to be computed freshly on dashboards irrespective of the value we
+ * retrieved from the change index.
+ */
+ public static final String
+ GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD =
+ "GerritBackendRequestFeature__enable_submit_requirements_backfilling_on_dashboard";
/**
* When set, we compute information from All-Users repository if able, instead of computing it
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index bfe1ee1..b9569e4 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -294,6 +294,10 @@
public static final FieldDef<ChangeData, Integer> OWNER =
integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
+ /** Uploader of the latest patch set. */
+ public static final FieldDef<ChangeData, Integer> UPLOADER =
+ integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+
/** References the source change number that this change was cherry-picked from. */
public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 30ab6e6a..9339d62 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -178,9 +178,14 @@
new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
/** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
+ @Deprecated
static final Schema<ChangeData> V70 =
new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
+ /** Added new field {@link ChangeField#UPLOADER}. */
+ static final Schema<ChangeData> V71 =
+ new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
+
/**
* Name of the change index to be used when contacting index backends or loading configurations.
*/
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index eabee65..338b984 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -53,6 +53,7 @@
import com.google.inject.Singleton;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.Serializable;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
@@ -108,7 +109,7 @@
@Singleton
public class CommitRewriter {
/** Options to run {@link #backfillProject}. */
- public static class RunOptions {
+ public static class RunOptions implements Serializable {
/** Whether to rewrite the commit history or only find refs that need to be fixed. */
public boolean dryRun = true;
/**
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
index 1a7d5af..d128633 100644
--- a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.notedb;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdateOp;
@@ -24,6 +26,7 @@
public class StoreSubmitRequirementsOp implements BatchUpdateOp {
private final ChangeData.Factory changeDataFactory;
private final SubmitRequirementsEvaluator evaluator;
+ private final boolean storeRequirementsInNoteDb;
public interface Factory {
StoreSubmitRequirementsOp create();
@@ -31,13 +34,23 @@
@Inject
public StoreSubmitRequirementsOp(
- ChangeData.Factory changeDataFactory, SubmitRequirementsEvaluator evaluator) {
+ ChangeData.Factory changeDataFactory,
+ ExperimentFeatures experimentFeatures,
+ SubmitRequirementsEvaluator evaluator) {
this.changeDataFactory = changeDataFactory;
this.evaluator = evaluator;
+ this.storeRequirementsInNoteDb =
+ experimentFeatures.isFeatureEnabled(
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE);
}
@Override
public boolean updateChange(ChangeContext ctx) throws Exception {
+ if (!storeRequirementsInNoteDb) {
+ // Temporarily stop storing submit requirements in NoteDb when the change is merged.
+ return false;
+ }
// Create ChangeData using the project/change IDs instead of ctx.getChange(). We do that because
// for changes requiring a rebase before submission (e.g. if submit type = RebaseAlways), the
// RebaseOp inserts a new patchset that is visible here (via Change#getCurrentPatchset). If we
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
index 9bf56d8..3caa4d4 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -34,14 +34,18 @@
SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
+ private static final FieldDescriptor SR_LEGACY_FIELD =
+ SubmitRequirementResultProto.getDescriptor().findFieldByNumber(6);
@Override
public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
SubmitRequirementResultProto.Builder builder = SubmitRequirementResultProto.newBuilder();
builder
.setSubmitRequirement(SubmitRequirementSerializer.serialize(r.submitRequirement()))
- .setLegacy(r.legacy())
.setCommit(ObjectIdConverter.create().toByteString(r.patchSetCommitId()));
+ if (r.legacy().isPresent()) {
+ builder.setLegacy(r.legacy().get());
+ }
if (r.applicabilityExpressionResult().isPresent()) {
builder.setApplicabilityExpressionResult(
SubmitRequirementExpressionResultSerializer.serialize(
@@ -61,10 +65,12 @@
public SubmitRequirementResult fromProto(SubmitRequirementResultProto proto) {
SubmitRequirementResult.Builder builder =
SubmitRequirementResult.builder()
- .legacy(proto.getLegacy())
.patchSetCommitId(ObjectIdConverter.create().fromByteString(proto.getCommit()))
.submitRequirement(
SubmitRequirementSerializer.deserialize(proto.getSubmitRequirement()));
+ if (proto.hasField(SR_LEGACY_FIELD)) {
+ builder.legacy(Optional.of(proto.getLegacy()));
+ }
if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
builder.applicabilityExpressionResult(
Optional.of(
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 513aeed..11ffcad 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -28,6 +28,7 @@
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
@@ -125,12 +126,18 @@
public static final String KEY_BRANCH = "branch";
public static final String SUBMIT_REQUIREMENT = "submit-requirement";
- public static final String KEY_SR_NAME = "name";
public static final String KEY_SR_DESCRIPTION = "description";
public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicableIf";
public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideIf";
public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
+ public static final ImmutableSet<String> SR_KEYS =
+ ImmutableSet.of(
+ KEY_SR_DESCRIPTION,
+ KEY_SR_APPLICABILITY_EXPRESSION,
+ KEY_SR_SUBMITTABILITY_EXPRESSION,
+ KEY_SR_OVERRIDE_EXPRESSION,
+ KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
public static final String KEY_MATCH = "match";
private static final String KEY_HTML = "html";
@@ -643,7 +650,7 @@
if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
// The config must not contain more than one parent to inherit from
// as there is no guarantee which of the parents would be used then.
- error(ValidationError.create(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
+ error("Cannot inherit from multiple projects");
}
p.setParent(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
@@ -696,10 +703,8 @@
String lower = name.toLowerCase();
if (lowerNames.containsKey(lower)) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+ String.format(
+ "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
}
lowerNames.put(lower, name);
extensionPanelSections.put(
@@ -725,26 +730,14 @@
ca.setAutoVerify(null);
} else if (rules.size() > 1) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- "Invalid rule in "
- + CONTRIBUTOR_AGREEMENT
- + "."
- + name
- + "."
- + KEY_AUTO_VERIFY
- + ": at most one group may be set"));
+ String.format(
+ "Invalid rule in %s.%s.%s: at most one group may be set",
+ CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY));
} else if (rules.get(0).getAction() != Action.ALLOW) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- "Invalid rule in "
- + CONTRIBUTOR_AGREEMENT
- + "."
- + name
- + "."
- + KEY_AUTO_VERIFY
- + ": the group must be allowed"));
+ String.format(
+ "Invalid rule in %s.%s.%s: the group must be allowed",
+ CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY));
} else {
ca.setAutoVerify(rules.get(0).getGroup());
}
@@ -792,21 +785,16 @@
if (ref.getUUID() != null) {
n.addGroup(ref);
} else {
- error(
- ValidationError.create(
- PROJECT_CONFIG,
- "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+ error(String.format("group \"%s\" not in %s", ref.getName(), GroupList.FILE_NAME));
}
} else if (dst.startsWith("user ")) {
- error(ValidationError.create(PROJECT_CONFIG, dst + " not supported"));
+ error(String.format("%s not supported", dst));
} else {
try {
n.addAddress(Address.parse(dst));
} catch (IllegalArgumentException err) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
+ String.format("notify section \"%s\" has invalid email \"%s\"", sectionName, dst));
}
}
}
@@ -867,7 +855,7 @@
try {
RefPattern.validateRegExp(refPattern);
} catch (InvalidNameException e) {
- error(ValidationError.create(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
+ error(String.format("Invalid ref name: %s", e.getMessage()));
return false;
}
return true;
@@ -895,9 +883,7 @@
// to fail fast if any of the patterns are invalid.
patterns.add(Pattern.compile(patternString).pattern());
} catch (PatternSyntaxException e) {
- error(
- ValidationError.create(
- PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
+ error(String.format("Invalid regular expression: %s", e.getMessage()));
continue;
}
}
@@ -924,15 +910,11 @@
rule = PermissionRule.fromString(ruleString, useRange);
} catch (IllegalArgumentException notRule) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- "Invalid rule in "
- + section
- + (subsection != null ? "." + subsection : "")
- + "."
- + varName
- + ": "
- + notRule.getMessage()));
+ String.format(
+ "Invalid rule in %s.%s: %s",
+ section + (subsection != null ? "." + subsection : ""),
+ varName,
+ notRule.getMessage()));
continue;
}
@@ -943,9 +925,7 @@
// all rules in the same file share the same GroupReference.
//
ref = groupList.resolve(rule.getGroup());
- error(
- ValidationError.create(
- PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+ error(String.format("group \"%s\" not in %s", ref.getName(), GroupList.FILE_NAME));
}
perm.add(rule.toBuilder().setGroup(ref));
@@ -964,17 +944,16 @@
}
private void loadSubmitRequirementSections(Config rc) {
+ checkForUnsupportedSubmitRequirementParams(rc);
+
Map<String, String> lowerNames = new HashMap<>();
submitRequirementSections = new LinkedHashMap<>();
for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
String lower = name.toLowerCase();
if (lowerNames.containsKey(lower)) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Submit requirement \"%s\" conflicts with \"%s\". Skipping the former.",
- name, lowerNames.get(lower))));
+ String.format(
+ "Submit requirement '%s' conflicts with '%s'.", name, lowerNames.get(lower)));
continue;
}
lowerNames.put(lower, name);
@@ -982,22 +961,34 @@
String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
- boolean canInherit =
- rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
-
- if (blockExpr == null) {
+ boolean canInherit;
+ try {
+ canInherit =
+ rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
+ } catch (IllegalArgumentException e) {
+ String canInheritValue =
+ rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
error(
- ValidationError.create(
- PROJECT_CONFIG,
- (String.format(
- "Submit requirement \"%s\" does not define a submittability expression."
- + " Skipping this requirement.",
- name))));
+ String.format(
+ "Invalid value %s.%s.%s for submit requirement '%s': %s",
+ SUBMIT_REQUIREMENT,
+ name,
+ KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+ name,
+ canInheritValue));
continue;
}
- // TODO(SR): add expressions validation. Expressions are stored as strings so we need to
- // validate their syntax.
+ if (blockExpr == null) {
+ error(
+ String.format(
+ "Setting a submittability expression for submit requirement '%s' is required:"
+ + " Missing %s.%s.%s",
+ name, SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION));
+ continue;
+ }
+
+ // The expressions are validated in SubmitRequirementExpressionsValidator.
SubmitRequirement submitRequirement =
SubmitRequirement.builder()
@@ -1013,16 +1004,50 @@
}
}
+ /**
+ * Report unsupported submit requirement parameters as errors.
+ *
+ * <p>Unsupported are submit requirements parameters that
+ *
+ * <ul>
+ * <li>are directly set in the {@code submit-requirement} section (as submit requirements are
+ * solely defined in subsections)
+ * <li>are unknown (maybe they were accidentally misspelled?)
+ * </ul>
+ */
+ private void checkForUnsupportedSubmitRequirementParams(Config rc) {
+ Set<String> directSubmitRequirementParams = rc.getNames(SUBMIT_REQUIREMENT);
+ if (!directSubmitRequirementParams.isEmpty()) {
+ error(
+ String.format(
+ "Submit requirements must be defined in %s.<name> subsections."
+ + " Setting parameters directly in the %s section is not allowed: %s",
+ SUBMIT_REQUIREMENT,
+ SUBMIT_REQUIREMENT,
+ directSubmitRequirementParams.stream().sorted().collect(toImmutableList())));
+ }
+
+ for (String subsection : rc.getSubsections(SUBMIT_REQUIREMENT)) {
+ ImmutableList<String> unknownSubmitRequirementParams =
+ rc.getNames(SUBMIT_REQUIREMENT, subsection).stream()
+ .filter(p -> !SR_KEYS.contains(p))
+ .collect(toImmutableList());
+ if (!unknownSubmitRequirementParams.isEmpty()) {
+ error(
+ String.format(
+ "Unsupported parameters for submit requirement '%s': %s",
+ subsection, unknownSubmitRequirementParams));
+ }
+ }
+ }
+
private void loadLabelSections(Config rc) {
Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
labelSections = new LinkedHashMap<>();
for (String name : rc.getSubsections(LABEL)) {
String lower = name.toLowerCase();
if (lowerNames.containsKey(lower)) {
- error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+ error(String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
}
lowerNames.put(lower, name);
@@ -1034,18 +1059,13 @@
if (allValues.add(labelValue.getValue())) {
values.add(labelValue);
} else {
- error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name)));
+ error(String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name));
}
} catch (IllegalArgumentException notValue) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Invalid %s \"%s\" for label \"%s\": %s",
- KEY_VALUE, value, name, notValue.getMessage())));
+ String.format(
+ "Invalid %s \"%s\" for label \"%s\": %s",
+ KEY_VALUE, value, name, notValue.getMessage()));
}
}
@@ -1053,7 +1073,7 @@
try {
label = LabelType.builder(name, values);
} catch (IllegalArgumentException badName) {
- error(ValidationError.create(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
+ error(String.format("Invalid label \"%s\"", name));
continue;
}
@@ -1064,11 +1084,9 @@
: Optional.of(LabelFunction.MAX_WITH_BLOCK);
if (!function.isPresent()) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Invalid %s for label \"%s\". Valid names are: %s",
- KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet()))));
+ String.format(
+ "Invalid %s for label \"%s\". Valid names are: %s",
+ KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet())));
}
label.setFunction(function.orElse(null));
label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
@@ -1078,11 +1096,7 @@
if (isInRange(dv, values)) {
label.setDefaultValue(dv);
} else {
- error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
+ error(String.format("Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name));
}
}
label.setAllowPostSubmit(
@@ -1131,18 +1145,13 @@
short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
if (!copyValues.add(copyValue)) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name)));
+ String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name));
}
} catch (IllegalArgumentException notValue) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Invalid %s \"%s\" for label \"%s\": %s",
- KEY_COPY_VALUE, value, name, notValue.getMessage())));
+ String.format(
+ "Invalid %s \"%s\" for label \"%s\": %s",
+ KEY_COPY_VALUE, value, name, notValue.getMessage()));
}
}
label.setCopyValues(copyValues);
@@ -1177,18 +1186,14 @@
commentLinkSections.put(name, buildCommentLink(rc, name, false));
} catch (PatternSyntaxException e) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Invalid pattern \"%s\" in commentlink.%s.match: %s",
- rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+ String.format(
+ "Invalid pattern \"%s\" in commentlink.%s.match: %s",
+ rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage()));
} catch (IllegalArgumentException e) {
error(
- ValidationError.create(
- PROJECT_CONFIG,
- String.format(
- "Error in pattern \"%s\" in commentlink.%s.match: %s",
- rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+ String.format(
+ "Error in pattern \"%s\" in commentlink.%s.match: %s",
+ rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage()));
}
}
}
@@ -1230,9 +1235,7 @@
if (groupName != null) {
GroupReference ref = groupList.byName(groupName);
if (ref == null) {
- error(
- ValidationError.create(
- PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
+ error(String.format("group \"%s\" not in %s", groupName, GroupList.FILE_NAME));
}
rc.setString(PLUGIN, plugin, name, value);
}
@@ -1782,11 +1785,15 @@
try {
return rc.getEnum(section, subsection, name, defaultValue);
} catch (IllegalArgumentException err) {
- error(ValidationError.create(PROJECT_CONFIG, err.getMessage()));
+ error(err.getMessage());
return defaultValue;
}
}
+ private void error(String errorMessage) {
+ error(ValidationError.create(PROJECT_CONFIG, errorMessage));
+ }
+
@Override
public void error(ValidationError error) {
if (validationErrors == null) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
new file mode 100644
index 0000000..738e71b
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -0,0 +1,184 @@
+// 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.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+@Singleton
+public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final DiffOperations diffOperations;
+ private final ProjectConfig.Factory projectConfigFactory;
+ private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
+
+ @Inject
+ SubmitRequirementExpressionsValidator(
+ DiffOperations diffOperations,
+ ProjectConfig.Factory projectConfigFactory,
+ SubmitRequirementsEvaluator submitRequirementsEvaluator) {
+ this.diffOperations = diffOperations;
+ this.projectConfigFactory = projectConfigFactory;
+ this.submitRequirementsEvaluator = submitRequirementsEvaluator;
+ }
+
+ @Override
+ public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+ throws CommitValidationException {
+ try {
+ if (!event.refName.equals(RefNames.REFS_CONFIG)
+ || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+ // the project.config file in refs/meta/config was not modified, hence we do not need to
+ // validate the submit requirements in it
+ return ImmutableList.of();
+ }
+
+ ProjectConfig projectConfig = getProjectConfig(event);
+ ImmutableList<CommitValidationMessage> validationMessages =
+ validateSubmitRequirementExpressions(
+ projectConfig.getSubmitRequirementSections().values());
+ if (!validationMessages.isEmpty()) {
+ throw new CommitValidationException(
+ String.format(
+ "invalid submit requirement expressions in %s (revision = %s)",
+ ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
+ validationMessages);
+ }
+ return ImmutableList.of();
+ } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+ String errorMessage =
+ String.format(
+ "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+ + " of project %s",
+ ProjectConfig.PROJECT_CONFIG,
+ event.commit.getName(),
+ RefNames.REFS_CONFIG,
+ event.project.getNameKey());
+ logger.atSevere().withCause(e).log(errorMessage);
+ throw new CommitValidationException(errorMessage, e);
+ }
+ }
+
+ /**
+ * Whether the given file was changed in the given revision.
+ *
+ * @param receiveEvent the receive event
+ * @param fileName the name of the file
+ */
+ private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+ throws DiffNotAvailableException {
+ return diffOperations
+ .listModifiedFilesAgainstParent(
+ receiveEvent.project.getNameKey(), receiveEvent.commit, /* parentNum=*/ 0)
+ .keySet().stream()
+ .anyMatch(fileName::equals);
+ }
+
+ private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+ throws IOException, ConfigInvalidException {
+ ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+ projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+ return projectConfig;
+ }
+
+ private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
+ Collection<SubmitRequirement> submitRequirements) {
+ List<CommitValidationMessage> validationMessages = new ArrayList<>();
+ for (SubmitRequirement submitRequirement : submitRequirements) {
+ validateSubmitRequirementExpression(
+ validationMessages,
+ submitRequirement,
+ submitRequirement.submittabilityExpression(),
+ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+ submitRequirement
+ .applicabilityExpression()
+ .ifPresent(
+ expression ->
+ validateSubmitRequirementExpression(
+ validationMessages,
+ submitRequirement,
+ expression,
+ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+ submitRequirement
+ .overrideExpression()
+ .ifPresent(
+ expression ->
+ validateSubmitRequirementExpression(
+ validationMessages,
+ submitRequirement,
+ expression,
+ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
+ }
+ return ImmutableList.copyOf(validationMessages);
+ }
+
+ private void validateSubmitRequirementExpression(
+ List<CommitValidationMessage> validationMessages,
+ SubmitRequirement submitRequirement,
+ SubmitRequirementExpression expression,
+ String configKey) {
+ try {
+ submitRequirementsEvaluator.validateExpression(expression);
+ } catch (QueryParseException e) {
+ if (validationMessages.isEmpty()) {
+ validationMessages.add(
+ new CommitValidationMessage(
+ "Invalid project configuration", ValidationMessage.Type.ERROR));
+ }
+ validationMessages.add(
+ new CommitValidationMessage(
+ String.format(
+ " %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+ + " invalid: %s",
+ ProjectConfig.PROJECT_CONFIG,
+ expression.expressionString(),
+ submitRequirement.name(),
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ submitRequirement.name(),
+ configKey,
+ e.getMessage()),
+ ValidationMessage.Type.ERROR));
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 7252fe9..539edc1 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -29,6 +29,7 @@
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.eclipse.jgit.lib.ObjectId;
@@ -88,7 +89,7 @@
.setAllowOverrideInChildProjects(labelType.isCanOverride());
result.add(
SubmitRequirementResult.builder()
- .legacy(true)
+ .legacy(Optional.of(true))
.submitRequirement(req.build())
.submittabilityExpressionResult(
createExpressionResult(toExpression(atoms), mapStatus(label), atoms))
@@ -111,7 +112,7 @@
.build();
return ImmutableList.of(
SubmitRequirementResult.builder()
- .legacy(true)
+ .legacy(Optional.of(true))
.submitRequirement(sr)
.submittabilityExpressionResult(
createExpressionResult(
@@ -130,7 +131,7 @@
.build();
result.add(
SubmitRequirementResult.builder()
- .legacy(true)
+ .legacy(Optional.of(true))
.submitRequirement(sr)
.submittabilityExpressionResult(
createExpressionResult(
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 2caa9dd..cc2c805 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -24,8 +24,6 @@
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.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
import com.google.inject.AbstractModule;
@@ -43,7 +41,6 @@
private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
private final ProjectCache projectCache;
private final SubmitRuleEvaluator.Factory legacyEvaluator;
- private final ExperimentFeatures experimentFeatures;
public static Module module() {
return new AbstractModule() {
@@ -60,12 +57,10 @@
private SubmitRequirementsEvaluatorImpl(
Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
ProjectCache projectCache,
- SubmitRuleEvaluator.Factory legacyEvaluator,
- ExperimentFeatures experimentFeatures) {
+ SubmitRuleEvaluator.Factory legacyEvaluator) {
this.queryBuilder = queryBuilder;
this.projectCache = projectCache;
this.legacyEvaluator = legacyEvaluator;
- this.experimentFeatures = experimentFeatures;
}
@Override
@@ -79,10 +74,7 @@
ChangeData cd, boolean includeLegacy) {
Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements = getRequirements(cd);
Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
- if (includeLegacy
- && experimentFeatures.isFeatureEnabled(
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)) {
+ if (includeLegacy) {
Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
result =
@@ -108,7 +100,7 @@
: Optional.empty();
return SubmitRequirementResult.builder()
- .legacy(false)
+ .legacy(Optional.of(false))
.submitRequirement(sr)
.patchSetCommitId(cd.currentPatchSet().commitId())
.submittabilityExpressionResult(blockingResult)
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index 2e43eac..102d3f2 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -50,10 +50,10 @@
result.putAll(projectConfigRequirements);
Map<String, SubmitRequirementResult> requirementsByName =
projectConfigRequirements.entrySet().stream()
- .collect(Collectors.toMap(sr -> sr.getKey().name(), sr -> sr.getValue()));
+ .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
legacyRequirements.entrySet()) {
- String name = legacy.getKey().name();
+ String name = legacy.getKey().name().toLowerCase();
SubmitRequirementResult projectConfigResult = requirementsByName.get(name);
SubmitRequirementResult legacyResult = legacy.getValue();
if (projectConfigResult != null && matchByStatus(projectConfigResult, legacyResult)) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index b8c8c07..9961519 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -948,6 +948,10 @@
* 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();
@@ -962,14 +966,8 @@
// Closed changes: Load submit requirement results from NoteDb.
Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
notes().getSubmitRequirementsResult().stream()
- .filter(r -> !r.legacy())
+ .filter(r -> !r.isLegacy())
.collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
- if (!experimentFeatures.isFeatureEnabled(
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)) {
- submitRequirements = projectConfigRequirements;
- return submitRequirements;
- }
Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
submitRequirements =
@@ -981,7 +979,15 @@
public void setSubmitRequirements(
Map<SubmitRequirement, SubmitRequirementResult> submitRequirements) {
- this.submitRequirements = submitRequirements;
+ if (!experimentFeatures.isFeatureEnabled(
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)) {
+ // Only set back values from the index if the experiment is not active. While the experiment
+ // is active, we want
+ // to compute SRs from scratch to ensure fresh results.
+ // TODO(ghareeb, hiesel): Remove this.
+ this.submitRequirements = submitRequirements;
+ }
}
public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 1a8e53e..3afdcdd 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -156,6 +156,14 @@
}
/**
+ * Returns a predicate that matches changes where the latest patch set was uploaded by the
+ * provided {@link com.google.gerrit.entities.Account.Id}.
+ */
+ public static Predicate<ChangeData> uploader(Account.Id id) {
+ return new ChangeIndexPredicate(ChangeField.UPLOADER, id.toString());
+ }
+
+ /**
* Returns a predicate that matches changes that are a cherry pick of the provided {@link
* com.google.gerrit.entities.Change.Id}.
*/
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 5454a4f..57191c5 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -197,6 +197,8 @@
public static final String FIELD_SUBMISSIONID = "submissionid";
public static final String FIELD_TR = "tr";
public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
+ public static final String FIELD_UPLOADER = "uploader";
+ public static final String FIELD_UPLOADERIN = "uploaderin";
public static final String FIELD_VISIBLETO = "visibleto";
public static final String FIELD_WATCHEDBY = "watchedby";
public static final String FIELD_WIP = "wip";
@@ -669,6 +671,14 @@
return ChangePredicates.owner(self());
}
+ if ("uploader".equalsIgnoreCase(value)) {
+ if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+ throw new QueryParseException(
+ "'is:uploader' operator is not supported by change index version");
+ }
+ return ChangePredicates.uploader(self());
+ }
+
if ("reviewer".equalsIgnoreCase(value)) {
if (args.getSchema().hasField(ChangeField.WIP)) {
return Predicate.and(
@@ -1185,6 +1195,23 @@
}
@Operator
+ public Predicate<ChangeData> uploader(String who)
+ throws QueryParseException, IOException, ConfigInvalidException {
+ if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+ throw new QueryParseException("'uploader' operator is not supported by change index version");
+ }
+ return uploader(parseAccount(who, (AccountState s) -> true));
+ }
+
+ private Predicate<ChangeData> uploader(Set<Account.Id> who) {
+ List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
+ for (Account.Id id : who) {
+ p.add(ChangePredicates.uploader(id));
+ }
+ return Predicate.or(p);
+ }
+
+ @Operator
public Predicate<ChangeData> attention(String who)
throws QueryParseException, IOException, ConfigInvalidException {
if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
@@ -1234,6 +1261,31 @@
}
@Operator
+ public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
+ if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+ throw new QueryParseException("'uploader' operator is not supported by change index version");
+ }
+
+ GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+ if (g == null) {
+ throw error("Group " + group + " not found");
+ }
+
+ AccountGroup.UUID groupId = g.getUUID();
+ GroupDescription.Basic groupDescription = args.groupBackend.get(groupId);
+ if (!(groupDescription instanceof GroupDescription.Internal)) {
+ return new UploaderinPredicate(args.userFactory, groupId);
+ }
+
+ Set<Account.Id> accounts = getMembers(groupId);
+ List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(accounts.size());
+ for (Account.Id id : accounts) {
+ p.add(ChangePredicates.uploader(id));
+ }
+ return Predicate.or(p);
+ }
+
+ @Operator
public Predicate<ChangeData> r(String who)
throws QueryParseException, IOException, ConfigInvalidException {
return reviewer(who);
diff --git a/java/com/google/gerrit/server/query/change/UploaderinPredicate.java b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
new file mode 100644
index 0000000..f44c0b5
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
@@ -0,0 +1,50 @@
+// 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.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.server.IdentifiedUser;
+
+/**
+ * Predicate that matches changes where the latest patch set was uploaded by a user in the provided
+ * group.
+ */
+public class UploaderinPredicate extends PostFilterPredicate<ChangeData> {
+ protected final IdentifiedUser.GenericFactory userFactory;
+ protected final AccountGroup.UUID uuid;
+
+ public UploaderinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+ super(ChangeQueryBuilder.FIELD_UPLOADERIN, uuid.get());
+ this.userFactory = userFactory;
+ this.uuid = uuid;
+ }
+
+ @Override
+ public boolean match(ChangeData cd) {
+ PatchSet latestPatchSet = cd.currentPatchSet();
+ if (latestPatchSet == null) {
+ return false;
+ }
+ IdentifiedUser uploader = userFactory.create(latestPatchSet.uploader());
+ return uploader.getEffectiveGroups().contains(uuid);
+ }
+
+ @Override
+ public int getCost() {
+ return 2;
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index e657c89..74407c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -225,7 +225,6 @@
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.PushResult;
-import org.junit.Ignore;
import org.junit.Test;
@NoHttpd
@@ -4059,7 +4058,7 @@
assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
assertThat(testLabel.appliedBy).isNull();
- voteLabel(changeId, "code-review", 2);
+ voteLabel(changeId, "Code-Review", 2);
// Code review record is satisfied after voting +2
change = gApi.changes().id(changeId).get();
assertThat(change.submitRecords).hasSize(2);
@@ -4159,13 +4158,16 @@
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withLabelEqualsMax() throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
+ .setName("Code-Review")
.setSubmittabilityExpression(
- SubmitRequirementExpression.create("label:code-review=MAX"))
+ SubmitRequirementExpression.create("label:Code-Review=MAX"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4175,16 +4177,19 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 2);
+ 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);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
}
@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
@@ -4204,14 +4209,15 @@
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
ChangeInfo change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ // The second requirement is coming from the legacy code-review label function
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
// Voting with a max vote as the uploader will not satisfy the submit requirement.
voteLabel(changeId, "my-label", 1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
@@ -4219,19 +4225,22 @@
requestScopeOperations.setApiUser(user.id());
voteLabel(changeId, "my-label", 1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
+ .setName("Code-Review")
.setSubmittabilityExpression(
- SubmitRequirementExpression.create("-label:code-review=MIN"))
+ SubmitRequirementExpression.create("-label:Code-Review=MIN"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4239,27 +4248,36 @@
String changeId = r.getChangeId();
ChangeInfo change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
// Requirement is satisfied because there are no votes
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+ // Legacy requirement (coming from the label function definition) is not satisfied. We return
+ // both legacy and non-legacy requirements in this case since their statuses are not identical.
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
- voteLabel(changeId, "code-review", -1);
+ voteLabel(changeId, "Code-Review", -1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
// Requirement is still satisfied because -1 is not the max negative value
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
- voteLabel(changeId, "code-review", -2);
+ voteLabel(changeId, "Code-Review", -2);
change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
// Requirement is now unsatisfied because -2 is the max negative value
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
}
@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
@@ -4286,7 +4304,8 @@
// Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
voteLabel(changeId, "my-label", -1);
ChangeInfo change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ // The other requirement is coming from the code-review label function
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
@@ -4294,7 +4313,7 @@
requestScopeOperations.setApiUser(user.id());
voteLabel(changeId, "my-label", 1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
@@ -4302,19 +4321,22 @@
requestScopeOperations.setApiUser(admin.id());
voteLabel(changeId, "my-label", 0);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_withLabelEqualsAny() throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
+ .setName("Code-Review")
.setSubmittabilityExpression(
- SubmitRequirementExpression.create("label:code-review=ANY"))
+ SubmitRequirementExpression.create("label:Code-Review=ANY"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4324,30 +4346,36 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 1);
+ voteLabel(changeId, "Code-Review", 1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
+ // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setAllowOverrideInChildProjects(false)
.build());
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("verified")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+ .setName("Verified")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4357,29 +4385,32 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
assertSubmitRequirementStatus(
- change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 2);
+ voteLabel(changeId, "Code-Review", 2);
change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
assertSubmitRequirementStatus(
- change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
+ .setName("Code-Review")
.setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4387,14 +4418,19 @@
String changeId = r.getChangeId();
ChangeInfo change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
}
@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.MAX_WITH_BLOCK);
+ configLabel("build-cop-override", LabelFunction.NO_BLOCK);
projectOperations
.project(project)
.forUpdate()
@@ -4408,8 +4444,8 @@
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4419,34 +4455,38 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
voteLabel(changeId, "build-cop-override", 1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.OVERRIDDEN, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
}
@Test
- @Ignore("Test is flaky")
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_overriddenInChildProject() throws Exception {
configSubmitRequirement(
allProjects,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
.setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
.setAllowOverrideInChildProjects(true)
.build());
- // Override submit requirement in child project (requires code-review=+2 instead of +1)
+ // Override submit requirement in child project (requires Code-Review=+2 instead of +1)
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4456,28 +4496,31 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 1);
+ voteLabel(changeId, "Code-Review", 1);
change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 2);
+ 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);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_inheritedFromParentProject() throws Exception {
configSubmitRequirement(
allProjects,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
.setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4487,34 +4530,40 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 1);
+ voteLabel(changeId, "Code-Review", 1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+ // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
throws Exception {
configSubmitRequirement(
allProjects,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
.setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
.setAllowOverrideInChildProjects(false)
.build());
- // Override submit requirement in child project (requires code-review=+2 instead of +1).
+ // Override submit requirement in child project (requires Code-Review=+2 instead of +1).
// Will have no effect since parent does not allow override.
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4524,17 +4573,27 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 1);
+ voteLabel(changeId, "Code-Review", 1);
change = gApi.changes().id(changeId).get();
- assertThat(change.submitRequirements).hasSize(1);
+ assertThat(change.submitRequirements).hasSize(2);
// +1 was enough to fulfill the requirement: override in child project was ignored
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+ // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+ assertSubmitRequirementStatus(
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ values = {
+ ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+ 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);
@@ -4542,9 +4601,9 @@
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
+ .setName("Code-Review")
.setSubmittabilityExpression(
- SubmitRequirementExpression.create("label:code-review=+2"))
+ SubmitRequirementExpression.create("label:Code-Review=+2"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4552,12 +4611,12 @@
createChange(repo, "master", "Add a file", "foo", "content", "topic");
String changeId = r.getChangeId();
- voteLabel(changeId, "code-review", 2);
+ voteLabel(changeId, "Code-Review", 2);
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
revision.review(ReviewInput.approve());
@@ -4571,17 +4630,24 @@
assertThat(result.submittabilityExpressionResult().status())
.isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
assertThat(result.submittabilityExpressionResult().expression().expressionString())
- .isEqualTo("label:code-review=+2");
+ .isEqualTo("label:Code-Review=+2");
}
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ values = {
+ ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+ })
public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setAllowOverrideInChildProjects(false)
.build());
@@ -4591,14 +4657,14 @@
ChangeInfo change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
- voteLabel(changeId, "code-review", 2);
+ 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);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
gApi.changes().id(changeId).current().submit();
@@ -4606,24 +4672,26 @@
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("verified")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+ .setName("Verified")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
.setAllowOverrideInChildProjects(false)
.build());
- // The new "verified" submit requirement is not returned, since this change is closed
+ // The new "Verified" submit requirement is not returned, since this change is closed
change = gApi.changes().id(changeId).get();
assertThat(change.submitRequirements).hasSize(1);
assertSubmitRequirementStatus(
- change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+ change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
}
@Test
@GerritConfig(
name = "experiments.enabled",
- value =
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)
+ values = {
+ ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+ })
public void
submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
throws Exception {
@@ -4686,9 +4754,7 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- value =
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void
submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
throws Exception {
@@ -4744,9 +4810,7 @@
@Test
@GerritConfig(
name = "experiments.enabled",
- value =
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)
+ 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
@@ -4799,19 +4863,22 @@
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setAllowOverrideInChildProjects(false)
.build());
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
- voteLabel(changeId, "code-review", 2);
+ voteLabel(changeId, "Code-Review", 2);
// Query the change. ChangeInfo is back-filled from the change index.
List<ChangeInfo> changeInfos =
@@ -4823,25 +4890,32 @@
assertThat(changeInfos).hasSize(1);
assertSubmitRequirementStatus(
changeInfos.get(0).submitRequirements,
- "code-review",
+ "Code-Review",
Status.SATISFIED,
/* isLegacy= */ false);
}
@Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ values = {
+ ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+ })
public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
configSubmitRequirement(
project,
SubmitRequirement.builder()
- .setName("code-review")
- .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+ .setName("Code-Review")
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
.setAllowOverrideInChildProjects(false)
.build());
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
- voteLabel(changeId, "code-review", 2);
+ voteLabel(changeId, "Code-Review", 2);
gApi.changes().id(changeId).current().submit();
// Query the change. ChangeInfo is back-filled from the change index.
@@ -4854,12 +4928,41 @@
assertThat(changeInfos).hasSize(1);
assertSubmitRequirementStatus(
changeInfos.get(0).submitRequirements,
- "code-review",
+ "Code-Review",
Status.SATISFIED,
/* isLegacy= */ false);
}
@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
public void fourByteEmoji() throws Exception {
// U+1F601 GRINNING FACE WITH SMILING EYES
String smile = new String(Character.toChars(0x1f601));
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index c6b57da..96db71a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,6 +19,7 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.entities.LegacySubmitRequirement;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.annotations.Exports;
@@ -28,6 +29,7 @@
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.rules.SubmitRule;
import com.google.inject.Inject;
@@ -201,6 +203,37 @@
assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
}
+ @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
+ })
+ public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
+ 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
+ assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
+ }
+
+ @Test
+ public void submitRuleIsNotInvokedWhenQueryingChangeWithoutExperiment() throws Exception {
+ 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 not invoked
+ assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
+ }
+
private List<ChangeInfo> queryIsSubmittable() throws Exception {
return gApi.changes().query("is:submittable project:" + project.get()).get();
}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 6e19c39..f511683 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -138,13 +138,13 @@
SubmitRequirement sr =
createSubmitRequirement(
/* applicabilityExpr= */ "project:" + project.get(),
- /* submittabilityExpr= */ "label:\"code-review=+2\"",
+ /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
/* overrideExpr= */ "");
SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
assertThat(result.submittabilityExpressionResult().failingAtoms())
- .containsExactly("label:\"code-review=+2\"");
+ .containsExactly("label:\"Code-Review=+2\"");
}
@Test
@@ -160,7 +160,7 @@
SubmitRequirement sr =
createSubmitRequirement(
/* applicabilityExpr= */ "project:" + project.get(),
- /* submittabilityExpr= */ "label:\"code-review=+2\"",
+ /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
/* overrideExpr= */ "label:\"build-cop-override=+1\"");
SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -175,7 +175,7 @@
SubmitRequirement sr =
createSubmitRequirement(
/* applicabilityExpr= */ "invalid_field:invalid_value",
- /* submittabilityExpr= */ "label:\"code-review=+2\"",
+ /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
/* overrideExpr= */ "label:\"build-cop-override=+1\"");
SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -206,7 +206,7 @@
SubmitRequirement sr =
createSubmitRequirement(
/* applicabilityExpr= */ "project:" + project.get(),
- /* submittabilityExpr= */ "label:\"code-review=+2\"",
+ /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
/* overrideExpr= */ "invalid_field:invalid_value");
SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
new file mode 100644
index 0000000..d8aa789
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -0,0 +1,455 @@
+// 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.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.util.Locale;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Test;
+
+/**
+ * Tests validating submit requirements on upload of {@code project.config} to {@code
+ * refs/meta/config}.
+ */
+public class SubmitRequirementsValidationIT extends AbstractDaemonTest {
+ @Test
+ public void validSubmitRequirementIsAccepted_optionalParametersNotSet() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\""));
+
+ PushResult r = pushRefsMetaConfig();
+ assertOkStatus(r);
+ }
+
+ @Test
+ public void validSubmitRequirementIsAccepted_allParametersSet() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ updateProjectConfig(
+ projectConfig -> {
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+ /* value= */ "foo bar description");
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+ /* value= */ "branch:refs/heads/master");
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\"");
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+ /* value= */ "label:\"override=+1\"");
+ projectConfig.setBoolean(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+ /* value= */ false);
+ });
+
+ PushResult r = pushRefsMetaConfig();
+ assertOkStatus(r);
+ }
+
+ @Test
+ public void parametersDirectlyInSubmitRequirementsSectionAreRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ updateProjectConfig(
+ projectConfig -> {
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ null,
+ /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+ /* value= */ "foo bar description");
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ null,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\"");
+ });
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Submit requirements must be defined in submit-requirement.<name>"
+ + " subsections. Setting parameters directly in the submit-requirement section is"
+ + " not allowed: [%s, %s]",
+ ProjectConfig.KEY_SR_DESCRIPTION, ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+ }
+
+ @Test
+ public void unsupportedParameterDirectlyInSubmitRequirementsSectionIsRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ null,
+ /* name= */ "unknown",
+ /* value= */ "value"));
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ "project.config: Submit requirements must be defined in submit-requirement.<name>"
+ + " subsections. Setting parameters directly in the submit-requirement section is"
+ + " not allowed: [unknown]");
+ }
+
+ @Test
+ public void unsupportedParameterForSubmitRequirementIsRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ "unknown",
+ /* value= */ "value"));
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Unsupported parameters for submit requirement '%s': [unknown]",
+ submitRequirementName));
+ }
+
+ @Test
+ public void conflictingSubmitRequirementsAreRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ updateProjectConfig(
+ projectConfig -> {
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\"");
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\"");
+ });
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Submit requirement '%s' conflicts with '%s'.",
+ submitRequirementName.toLowerCase(Locale.US), submitRequirementName));
+ }
+
+ @Test
+ public void conflictingSubmitRequirementIsRejected() throws Exception {
+ fetchRefsMetaConfig();
+ String submitRequirementName = "Code-Review";
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\""));
+ PushResult r = pushRefsMetaConfig();
+ assertOkStatus(r);
+
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\""));
+ r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Submit requirement '%s' conflicts with '%s'.",
+ submitRequirementName.toLowerCase(Locale.US), submitRequirementName));
+ }
+
+ @Test
+ public void submitRequirementWithoutSubmittabilityExpressionIsRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+ /* value= */ "foo bar description"));
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Setting a submittability expression for submit requirement '%s' is"
+ + " required: Missing %s.%s.%s",
+ submitRequirementName,
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ submitRequirementName,
+ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+ }
+
+ @Test
+ public void submitRequirementWithInvalidSubmittabilityExpressionIsRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ String invalidExpression = "invalid_field:invalid_value";
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ invalidExpression));
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+ + " invalid: Unsupported operator %s",
+ invalidExpression,
+ submitRequirementName,
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ submitRequirementName,
+ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ invalidExpression));
+ }
+
+ @Test
+ public void submitRequirementWithInvalidApplicabilityExpressionIsRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ String invalidExpression = "invalid_field:invalid_value";
+ updateProjectConfig(
+ projectConfig -> {
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\"");
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+ /* value= */ invalidExpression);
+ });
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+ + " invalid: Unsupported operator %s",
+ invalidExpression,
+ submitRequirementName,
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ submitRequirementName,
+ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+ invalidExpression));
+ }
+
+ @Test
+ public void submitRequirementWithInvalidOverrideExpressionIsRejected() throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ String invalidExpression = "invalid_field:invalid_value";
+ updateProjectConfig(
+ projectConfig -> {
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ /* value= */ "label:\"Code-Review=+2\"");
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+ /* value= */ invalidExpression);
+ });
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+ + " invalid: Unsupported operator %s",
+ invalidExpression,
+ submitRequirementName,
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ submitRequirementName,
+ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+ invalidExpression));
+ }
+
+ @Test
+ public void submitRequirementWithInvalidAllowOverrideInChildProjectsIsRejected()
+ throws Exception {
+ fetchRefsMetaConfig();
+
+ String submitRequirementName = "Code-Review";
+ String invalidValue = "invalid";
+ updateProjectConfig(
+ projectConfig ->
+ projectConfig.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ /* subsection= */ submitRequirementName,
+ /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+ /* value= */ invalidValue));
+
+ PushResult r = pushRefsMetaConfig();
+ assertErrorStatus(
+ r,
+ "Invalid project configuration",
+ String.format(
+ "project.config: Invalid value %s.%s.%s for submit requirement '%s': %s",
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ submitRequirementName,
+ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+ submitRequirementName,
+ invalidValue));
+ }
+
+ private void fetchRefsMetaConfig() throws Exception {
+ fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+ testRepo.reset(RefNames.REFS_CONFIG);
+ }
+
+ private PushResult pushRefsMetaConfig() throws Exception {
+ return pushHead(testRepo, RefNames.REFS_CONFIG);
+ }
+
+ private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+ RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+ Config projectConfig = readProjectConfig(head);
+ configUpdater.accept(projectConfig);
+ RevCommit commit =
+ testRepo.update(
+ RefNames.REFS_CONFIG,
+ testRepo
+ .commit()
+ .parent(head)
+ .message("Update project config")
+ .author(admin.newIdent())
+ .committer(admin.newIdent())
+ .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+
+ testRepo.reset(commit);
+ }
+
+ private Config readProjectConfig(RevCommit commit) throws Exception {
+ try (TreeWalk tw =
+ TreeWalk.forPath(
+ testRepo.getRevWalk().getObjectReader(),
+ ProjectConfig.PROJECT_CONFIG,
+ commit.getTree())) {
+ if (tw == null) {
+ throw new IllegalStateException(
+ String.format("%s does not exist", ProjectConfig.PROJECT_CONFIG));
+ }
+ }
+ RevObject blob = testRepo.get(commit.getTree(), ProjectConfig.PROJECT_CONFIG);
+ byte[] data = testRepo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+ String content = RawParseUtils.decode(data);
+
+ Config projectConfig = new Config();
+ projectConfig.fromText(content);
+ return projectConfig;
+ }
+
+ public void assertOkStatus(PushResult result) {
+ RemoteRefUpdate refUpdate = result.getRemoteUpdate(RefNames.REFS_CONFIG);
+ assertThat(refUpdate).isNotNull();
+ assertWithMessage(getMessage(result, refUpdate))
+ .that(refUpdate.getStatus())
+ .isEqualTo(Status.OK);
+ }
+
+ public void assertErrorStatus(PushResult result, String... expectedMessages) {
+ RemoteRefUpdate refUpdate = result.getRemoteUpdate(RefNames.REFS_CONFIG);
+ assertThat(refUpdate).isNotNull();
+ assertWithMessage(getMessage(result, refUpdate))
+ .that(refUpdate.getStatus())
+ .isEqualTo(Status.REJECTED_OTHER_REASON);
+ for (String expectedMessage : expectedMessages) {
+ assertThat(result.getMessages()).contains(expectedMessage);
+ }
+ }
+
+ private String getMessage(PushResult result, RemoteRefUpdate refUpdate) {
+ StringBuilder b = new StringBuilder();
+ if (refUpdate.getMessage() != null) {
+ b.append(refUpdate.getMessage());
+ b.append("\n");
+ }
+ b.append(result.getMessages());
+ return b.toString();
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
index a1dee1a..6993dfe 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -26,7 +26,7 @@
public class SubmitRequirementSerializerTest {
private static final SubmitRequirement submitReq =
SubmitRequirement.builder()
- .setName("code-review")
+ .setName("Code-Review")
.setDescription(Optional.of("require code review +2"))
.setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
.setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 52eaf11..0c26f1a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -687,7 +687,7 @@
.submitRequirementsResult(
ImmutableList.of(
SubmitRequirementResult.builder()
- .legacy(true)
+ .legacy(Optional.of(true))
.patchSetCommitId(
ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
.submitRequirement(
@@ -696,7 +696,7 @@
.setApplicabilityExpression(
SubmitRequirementExpression.of("project:foo"))
.setSubmittabilityExpression(
- SubmitRequirementExpression.create("label:code-review=+2"))
+ SubmitRequirementExpression.create("label:Code-Review=+2"))
.setAllowOverrideInChildProjects(false)
.build())
.applicabilityExpressionResult(
@@ -708,10 +708,10 @@
ImmutableList.of())))
.submittabilityExpressionResult(
SubmitRequirementExpressionResult.create(
- SubmitRequirementExpression.create("label:code-review=+2"),
+ SubmitRequirementExpression.create("label:Code-Review=+2"),
SubmitRequirementExpressionResult.Status.FAIL,
ImmutableList.of(),
- ImmutableList.of("label:code-review=+2")))
+ ImmutableList.of("label:Code-Review=+2")))
.build()))
.build(),
newProtoBuilder()
@@ -726,7 +726,7 @@
SubmitRequirementProto.newBuilder()
.setName("Code-Review")
.setApplicabilityExpression("project:foo")
- .setSubmittabilityExpression("label:code-review=+2")
+ .setSubmittabilityExpression("label:Code-Review=+2")
.setAllowOverrideInChildProjects(false)
.build())
.setApplicabilityExpressionResult(
@@ -737,9 +737,9 @@
.build())
.setSubmittabilityExpressionResult(
SubmitRequirementExpressionResultProto.newBuilder()
- .setExpression("label:code-review=+2")
+ .setExpression("label:Code-Review=+2")
.setStatus("FAIL")
- .addFailingAtoms("label:code-review=+2")
+ .addFailingAtoms("label:Code-Review=+2")
.build())
.build())
.build());
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 7f0b685..aed1648 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -217,7 +217,7 @@
"[submit-requirement \"Code-review\"]\n"
+ " description = At least one Code Review +2\n"
+ " applicableIf =branch(refs/heads/master)\n"
- + " submittableIf = label(code-review, +2)\n"
+ + " submittableIf = label(Code-Review, +2)\n"
+ "[submit-requirement \"api-review\"]\n"
+ " description = Additional review required for API modifications\n"
+ " applicableIf =commit_filepath_contains(\\\"/api/.*\\\")\n"
@@ -237,7 +237,7 @@
.setApplicabilityExpression(
SubmitRequirementExpression.of("branch(refs/heads/master)"))
.setSubmittabilityExpression(
- SubmitRequirementExpression.create("label(code-review, +2)"))
+ SubmitRequirementExpression.create("label(Code-Review, +2)"))
.setOverrideExpression(Optional.empty())
.setAllowOverrideInChildProjects(false)
.build(),
@@ -262,19 +262,19 @@
.add("groups", group(developers))
.add(
"project.config",
- "[submit-requirement \"code-review\"]\n"
- + " submittableIf = label(code-review, +2)\n")
+ "[submit-requirement \"Code-Review\"]\n"
+ + " submittableIf = label(Code-Review, +2)\n")
.create();
ProjectConfig cfg = read(rev);
Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
assertThat(submitRequirements)
.containsExactly(
- "code-review",
+ "Code-Review",
SubmitRequirement.builder()
- .setName("code-review")
+ .setName("Code-Review")
.setSubmittabilityExpression(
- SubmitRequirementExpression.create("label(code-review, +2)"))
+ SubmitRequirementExpression.create("label(Code-Review, +2)"))
.setAllowOverrideInChildProjects(false)
.build());
}
@@ -310,9 +310,7 @@
assertThat(cfg.getValidationErrors()).hasSize(1);
assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
.isEqualTo(
- "project.config: "
- + "Submit requirement \"Code-Review\" conflicts with \"code-review\". "
- + "Skipping the former.");
+ "project.config: Submit requirement 'Code-Review' conflicts with 'code-review'.");
}
@Test
@@ -322,8 +320,8 @@
.add("groups", group(developers))
.add(
"project.config",
- "[submit-requirement \"code-review\"]\n"
- + " applicableIf =label(code-review, +2)\n")
+ "[submit-requirement \"Code-Review\"]\n"
+ + " applicableIf =label(Code-Review, +2)\n")
.create();
ProjectConfig cfg = read(rev);
@@ -332,8 +330,9 @@
assertThat(cfg.getValidationErrors()).hasSize(1);
assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
.isEqualTo(
- "project.config: Submit requirement \"code-review\" does not define a submittability"
- + " expression. Skipping this requirement.");
+ "project.config: Setting a submittability expression for submit requirement"
+ + " 'Code-Review' is required: Missing"
+ + " submit-requirement.Code-Review.submittableIf");
}
@Test
@@ -954,10 +953,10 @@
tr.commit()
.add(
"project.config",
- "[submit-requirement \"code-review\"]\n"
+ "[submit-requirement \"Code-Review\"]\n"
+ " description = At least one Code Review +2\n"
+ " applicableIf =branch(refs/heads/master)\n"
- + " submittableIf = label(code-review, +2)\n"
+ + " submittableIf = label(Code-Review, +2)\n"
+ "[notify \"name\"]\n"
+ " email = example@example.com\n")
.create();
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index c7b5a7d..3b671aa 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -602,6 +602,29 @@
}
@Test
+ public void byUploader() throws Exception {
+ assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+ Account.Id user2 =
+ accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+ CurrentUser user2CurrentUser = userFactory.create(user2);
+
+ TestRepository<Repo> repo = createProject("repo");
+ Change change1 = insert(repo, newChange(repo), userId);
+ assertQuery("is:uploader", change1);
+ assertQuery("uploader:" + userId.get(), change1);
+ change1 = newPatchSet(repo, change1, user2CurrentUser);
+ // Uploader has changed
+ assertQuery("uploader:" + userId.get());
+ assertQuery("uploader:" + user2.get(), change1);
+
+ requestContext.setContext(newRequestContext(user2));
+ assertQuery("is:uploader", change1); // self (user2)
+
+ String nameEmail = user2CurrentUser.asIdentifiedUser().getNameEmail();
+ assertQuery("uploader: \"" + nameEmail + "\"", change1);
+ }
+
+ @Test
public void byAuthorExact() throws Exception {
assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
byAuthorOrCommitterExact("author:");
@@ -717,6 +740,20 @@
}
@Test
+ public void byUploaderIn() throws Exception {
+ assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+ TestRepository<Repo> repo = createProject("repo");
+ Change change1 = insert(repo, newChange(repo), userId);
+ assertQuery("uploaderin:Administrators", change1);
+
+ Account.Id user2 =
+ accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+ CurrentUser user2CurrentUser = userFactory.create(user2);
+ newPatchSet(repo, change1, user2CurrentUser);
+ assertQuery("uploaderin:Administrators");
+ }
+
+ @Test
public void byProject() throws Exception {
TestRepository<Repo> repo1 = createProject("repo1");
TestRepository<Repo> repo2 = createProject("repo2");
@@ -2722,7 +2759,7 @@
gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
PatchSet.Id ps3_1 = change3.currentPatchSetId();
- change3 = newPatchSet(repo, change3);
+ change3 = newPatchSet(repo, change3, user);
assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
// Response to previous patch set still counts as reviewing.
gApi.changes()
@@ -4028,7 +4065,8 @@
}
}
- protected Change newPatchSet(TestRepository<Repo> repo, Change c) throws Exception {
+ protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
+ throws Exception {
// Add a new file so the patch set is not a trivial rebase, to avoid default
// Code-Review label copying.
int n = c.currentPatchSetId().get() + 1;
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index f86e825..39b40b6 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1081,6 +1081,7 @@
applicability_expression_result?: SubmitRequirementExpressionInfo;
submittability_expression_result: SubmitRequirementExpressionInfo;
override_expression_result?: SubmitRequirementExpressionInfo;
+ is_legacy?: boolean;
}
/**
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 645e770..6ff2894 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -256,7 +256,6 @@
export function createDefaultPreferences() {
return {
changes_per_page: 25,
- default_diff_view: DiffViewMode.SIDE_BY_SIDE,
diff_view: DiffViewMode.SIDE_BY_SIDE,
size_bar_in_change_table: true,
} as PreferencesInfo;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
new file mode 100644
index 0000000..2b9abfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
+import {changeIsMerged} from '../../../utils/change-util';
+
+@customElement('gr-change-list-column-requirements')
+export class GrChangeListColumRequirements extends LitElement {
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ static override get styles() {
+ return [
+ css`
+ iron-icon {
+ width: var(--line-height-normal, 20px);
+ height: var(--line-height-normal, 20px);
+ vertical-align: top;
+ }
+ span {
+ line-height: var(--line-height-normal);
+ }
+ .check {
+ color: var(--success-foreground);
+ }
+ iron-icon.close {
+ color: var(--error-foreground);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (changeIsMerged(this.change)) {
+ return this.renderState('check', 'Merged');
+ }
+
+ const submitRequirements = (this.change?.submit_requirements ?? []).filter(
+ req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
+ );
+ if (!submitRequirements.length) return html`n/a`;
+ const numOfRequirements = submitRequirements.length;
+ const numOfSatisfiedRequirements = submitRequirements.filter(
+ req => req.status === SubmitRequirementStatus.SATISFIED
+ ).length;
+
+ if (numOfSatisfiedRequirements === numOfRequirements) {
+ return this.renderState('check', 'Ready');
+ }
+ return this.renderState(
+ 'close',
+ `${numOfSatisfiedRequirements} of ${numOfRequirements} granted`
+ );
+ }
+
+ renderState(icon: string, message: string) {
+ return html`<span class="${icon}"
+ ><gr-submit-requirement-dashboard-hovercard .change=${this.change}>
+ </gr-submit-requirement-dashboard-hovercard>
+ <iron-icon class="${icon}" icon="gr-icons:${icon}" role="img"></iron-icon
+ >${message}</span
+ >`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list-column-requirements': GrChangeListColumRequirements;
+ }
+}
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 c476d2d..43f6730 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
@@ -26,6 +26,8 @@
import '../../../styles/shared-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../gr-change-list-column/gr-change-list-column';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-change-list-item_html';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index a0aa962..c59b2e3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -290,6 +290,13 @@
</template>
</gr-tooltip-content>
</td>
+ <td
+ class="cell requirements"
+ hidden$="[[_computeIsColumnHidden('Requirements', visibleChangeTableColumns)]]"
+ >
+ <gr-change-list-column-requirements change="[[change]]">
+ </gr-change-list-column-requirements>
+ </td>
<template is="dom-repeat" items="[[labelNames]]" as="labelName">
<td
title$="[[_computeLabelTitle(change, 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 34cb6eb..35be3de 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
@@ -369,6 +369,7 @@
'Branch',
'Updated',
'Size',
+ 'Requirements',
];
await flush();
@@ -392,6 +393,7 @@
'Branch',
'Updated',
'Size',
+ 'Requirements',
];
await flush();
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 a3db699..66cef20 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
@@ -27,6 +27,7 @@
import {
KeyboardShortcutMixin,
Shortcut,
+ ShortcutListener,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {
GerritNav,
@@ -46,10 +47,10 @@
PreferencesInput,
} from '../../../types/common';
import {hasAttention} from '../../../utils/attention-set-util';
-import {IronKeyboardEvent} from '../../../types/events';
import {fireEvent, fireReload} from '../../../utils/event-util';
-import {isShiftPressed, modifierPressed} from '../../../utils/dom-util';
import {ScrollMode} from '../../../constants/constants';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {KnownExperimentId} from '../../../services/flags/flags';
const NUMBER_FIXED_COLUMNS = 3;
const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -67,6 +68,7 @@
'Branch',
'Updated',
'Size',
+ 'Requirements',
];
export interface ChangeListSection {
@@ -135,9 +137,6 @@
@property({type: Boolean})
showReviewedState = false;
- @property({type: Object})
- keyEventTarget: HTMLElement = document.body;
-
@property({type: Array})
changeTableColumns?: string[];
@@ -157,19 +156,19 @@
private readonly restApiService = appContext.restApiService;
- private readonly shortcuts = appContext.shortcutsService;
-
- override keyboardShortcuts() {
- return {
- [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
- [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
- [Shortcut.NEXT_PAGE]: '_nextPage',
- [Shortcut.PREV_PAGE]: '_prevPage',
- [Shortcut.OPEN_CHANGE]: '_openChange',
- [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
- [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
- [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
- };
+ override keyboardShortcuts(): ShortcutListener[] {
+ return [
+ listen(Shortcut.CURSOR_NEXT_CHANGE, _ => this._nextChange()),
+ listen(Shortcut.CURSOR_PREV_CHANGE, _ => this._prevChange()),
+ listen(Shortcut.NEXT_PAGE, _ => this._nextPage()),
+ listen(Shortcut.PREV_PAGE, _ => this._prevPage()),
+ listen(Shortcut.OPEN_CHANGE, _ => this.openChange()),
+ listen(Shortcut.TOGGLE_CHANGE_REVIEWED, _ =>
+ this._toggleChangeReviewed()
+ ),
+ listen(Shortcut.TOGGLE_CHANGE_STAR, _ => this._toggleChangeStar()),
+ listen(Shortcut.REFRESH_CHANGE_LIST, _ => this._refreshChangeList()),
+ ];
}
private cursor = new GrCursorManager();
@@ -204,7 +203,7 @@
}
/**
- * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+ * shortcut-service catches keyboard events globally. Some keyboard
* events must be scoped to a component level (e.g. `enter`) in order to not
* override native browser functionality.
*
@@ -213,7 +212,7 @@
_scopedKeydownHandler(e: KeyboardEvent) {
if (e.keyCode === 13) {
// Enter.
- this.openChange(e);
+ this.openChange();
}
}
@@ -265,6 +264,8 @@
if (!config || !config.change) return true;
if (column === 'Assignee') return !!config.change.enable_assignee;
if (column === 'Comments') return experiments.includes('comments-column');
+ if (column === 'Requirements')
+ return experiments.includes(KnownExperimentId.SUBMIT_REQUIREMENTS_UI);
return true;
}
@@ -406,63 +407,30 @@
);
}
- _nextChange(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _nextChange() {
this.isCursorMoving = true;
this.cursor.next();
this.isCursorMoving = false;
this.selectedIndex = this.cursor.index;
}
- _prevChange(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _prevChange() {
this.isCursorMoving = true;
this.cursor.previous();
this.isCursorMoving = false;
this.selectedIndex = this.cursor.index;
}
- _openChange(e: IronKeyboardEvent) {
- if (this.shortcuts.modifierPressed(e)) return;
- this.openChange(e.detail.keyboardEvent);
- }
-
- openChange(e: KeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || modifierPressed(e)) return;
- e.preventDefault();
+ openChange() {
const change = this._changeForIndex(this.selectedIndex);
if (change) GerritNav.navigateToChange(change);
}
- _nextPage(e: IronKeyboardEvent) {
- if (
- this.shortcuts.shouldSuppress(e) ||
- (this.shortcuts.modifierPressed(e) && !isShiftPressed(e))
- ) {
- return;
- }
-
- e.preventDefault();
+ _nextPage() {
fireEvent(this, 'next-page');
}
- _prevPage(e: IronKeyboardEvent) {
- if (
- this.shortcuts.shouldSuppress(e) ||
- (this.shortcuts.modifierPressed(e) && !isShiftPressed(e))
- ) {
- return;
- }
-
- e.preventDefault();
+ _prevPage() {
this.dispatchEvent(
new CustomEvent('previous-page', {
composed: true,
@@ -471,12 +439,7 @@
);
}
- _toggleChangeReviewed(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _toggleChangeReviewed() {
this._toggleReviewedForIndex(this.selectedIndex);
}
@@ -490,21 +453,11 @@
changeEl.toggleReviewed();
}
- _refreshChangeList(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
-
- e.preventDefault();
+ _refreshChangeList() {
fireReload(this);
}
- _toggleChangeStar(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _toggleChangeStar() {
this._toggleStarForIndex(this.selectedIndex);
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 4956380..472435c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -147,38 +147,37 @@
await flush();
const promise = mockPromise();
afterNextRender(element, () => {
- const elementItems = element.root.querySelectorAll(
- 'gr-change-list-item');
- assert.equal(elementItems.length, 3);
-
- assert.isTrue(elementItems[0].hasAttribute('selected'));
- MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
- assert.equal(element.selectedIndex, 1);
- assert.isTrue(elementItems[1].hasAttribute('selected'));
- MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
- assert.equal(element.selectedIndex, 2);
- assert.isTrue(elementItems[2].hasAttribute('selected'));
-
- const navStub = sinon.stub(GerritNav, 'navigateToChange');
- assert.equal(element.selectedIndex, 2);
- MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
- assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
- 'Should navigate to /c/2/');
-
- MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
- assert.equal(element.selectedIndex, 1);
- MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
- assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
- 'Should navigate to /c/1/');
-
- MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
- MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
- MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
- assert.equal(element.selectedIndex, 0);
-
promise.resolve();
});
await promise;
+ const elementItems = element.root.querySelectorAll(
+ 'gr-change-list-item');
+ assert.equal(elementItems.length, 3);
+
+ assert.isTrue(elementItems[0].hasAttribute('selected'));
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+ assert.equal(element.selectedIndex, 1);
+ assert.isTrue(elementItems[1].hasAttribute('selected'));
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+ assert.equal(element.selectedIndex, 2);
+ assert.isTrue(elementItems[2].hasAttribute('selected'));
+
+ const navStub = sinon.stub(GerritNav, 'navigateToChange');
+ assert.equal(element.selectedIndex, 2);
+ MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+ assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+ 'Should navigate to /c/2/');
+
+ MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+ assert.equal(element.selectedIndex, 1);
+ MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+ assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+ 'Should navigate to /c/1/');
+
+ MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+ MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+ MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+ assert.equal(element.selectedIndex, 0);
});
test('no changes', () => {
@@ -283,6 +282,7 @@
'Branch',
'Updated',
'Size',
+ 'Requirements',
],
};
element._config = {};
@@ -320,6 +320,7 @@
'Branch',
'Updated',
'Size',
+ 'Requirements',
],
};
element._config = {};
@@ -449,45 +450,45 @@
await flush();
const promise = mockPromise();
afterNextRender(element, () => {
- const elementItems = element.root.querySelectorAll(
- 'gr-change-list-item');
- assert.equal(elementItems.length, 9);
-
- MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
- assert.equal(element.selectedIndex, 1);
- MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-
- const navStub = sinon.stub(GerritNav, 'navigateToChange');
- assert.equal(element.selectedIndex, 2);
-
- MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
- assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
- 'Should navigate to /c/2/');
-
- MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
- assert.equal(element.selectedIndex, 1);
- MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
- assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
- 'Should navigate to /c/1/');
-
- MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
- MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
- MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
- assert.equal(element.selectedIndex, 4);
- MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
- assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
- 'Should navigate to /c/4/');
-
- MockInteractions.keyUpOn(element, 82); // 'r'
- const change = element._changeForIndex(element.selectedIndex);
- assert.equal(change.reviewed, true,
- 'Should mark change as reviewed');
- MockInteractions.keyUpOn(element, 82); // 'r'
- assert.equal(change.reviewed, false,
- 'Should mark change as unreviewed');
promise.resolve();
});
await promise;
+ const elementItems = element.root.querySelectorAll(
+ 'gr-change-list-item');
+ assert.equal(elementItems.length, 9);
+
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+ assert.equal(element.selectedIndex, 1);
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+
+ const navStub = sinon.stub(GerritNav, 'navigateToChange');
+ assert.equal(element.selectedIndex, 2);
+
+ MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+ assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+ 'Should navigate to /c/2/');
+
+ MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+ assert.equal(element.selectedIndex, 1);
+ MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+ assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+ 'Should navigate to /c/1/');
+
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+ assert.equal(element.selectedIndex, 4);
+ MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+ assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
+ 'Should navigate to /c/4/');
+
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+ const change = element._changeForIndex(element.selectedIndex);
+ assert.equal(change.reviewed, true,
+ 'Should mark change as reviewed');
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+ assert.equal(change.reviewed, false,
+ 'Should mark change as unreviewed');
});
test('_computeItemHighlight gives false for null account', () => {
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 891482d..b10c17e 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
@@ -95,9 +95,9 @@
} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
import {
- CODE_REVIEW,
getApprovalInfo,
getVotingRange,
+ StandardLabels,
} from '../../../utils/label-util';
import {CommentThread} from '../../../utils/comment-util';
import {ShowAlertEventDetail} from '../../../types/events';
@@ -975,8 +975,9 @@
// Allow the user to use quick approve to vote the max score on code review
// even if it is already granted by someone else. Does not apply if the
// user owns the change or has already granted the max score themselves.
- const codeReviewLabel = this.change.labels[CODE_REVIEW];
- const codeReviewPermittedValues = this.change.permitted_labels[CODE_REVIEW];
+ const codeReviewLabel = this.change.labels[StandardLabels.CODE_REVIEW];
+ const codeReviewPermittedValues =
+ this.change.permitted_labels[StandardLabels.CODE_REVIEW];
if (
!result &&
codeReviewLabel &&
@@ -988,7 +989,7 @@
getApprovalInfo(codeReviewLabel, this.account)?.value !==
getVotingRange(codeReviewLabel)?.max
) {
- result = CODE_REVIEW;
+ result = StandardLabels.CODE_REVIEW;
}
if (result) {
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 97101f6..25dab25 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
@@ -256,7 +256,6 @@
id="assigneeValue"
placeholder="Set assignee..."
max-count="1"
- skip-suggest-on-empty=""
accounts="{{_assignee}}"
readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
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 c19be58e..1a4b7a0 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
@@ -48,11 +48,12 @@
import {
KeyboardShortcutMixin,
Shortcut,
+ ShortcutListener,
ShortcutSection,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {GrEditConstants} from '../../edit/gr-edit-constants';
import {pluralize} from '../../../utils/string-util';
-import {windowLocationReload, querySelectorAll} from '../../../utils/dom-util';
+import {querySelectorAll, windowLocationReload} from '../../../utils/dom-util';
import {
GeneratedWebLink,
GerritNav,
@@ -62,8 +63,8 @@
import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
import {DiffViewMode} from '../../../api/diff';
import {
- DefaultBase,
ChangeStatus,
+ DefaultBase,
PrimaryTab,
SecondaryTab,
} from '../../../constants/constants';
@@ -83,9 +84,9 @@
changeIsOpen,
changeStatuses,
isCc,
+ isInvolved,
isOwner,
isReviewer,
- isInvolved,
} from '../../../utils/change-util';
import {EventType as PluginEventType} from '../../../api/plugin';
import {customElement, observe, property} from '@polymer/decorators';
@@ -158,9 +159,7 @@
ParsedChangeInfo,
} from '../../../types/types';
import {
- IronKeyboardEventListener,
CloseFixPreviewEvent,
- IronKeyboardEvent,
EditableContentSaveEvent,
EventType,
OpenFixPreviewEvent,
@@ -192,10 +191,12 @@
drafts$,
} from '../../../services/comments/comments-model';
import {
- hasAttention,
getAddedByReason,
getRemovedByReason,
+ hasAttention,
} from '../../../utils/attention-set-util';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {preferenceDiffViewMode$} from '../../../services/user/user-model';
const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
@@ -295,9 +296,6 @@
@property({type: Boolean})
hasParent?: boolean;
- @property({type: Object})
- keyEventTarget = document.body;
-
@property({type: Boolean})
disableEdit = false;
@@ -529,7 +527,7 @@
@property({type: Boolean})
_showRobotCommentsButton = false;
- _throttledToggleChangeStar?: IronKeyboardEventListener;
+ _throttledToggleChangeStar?: (e: KeyboardEvent) => void;
@property({type: Boolean})
_showChecksTab = false;
@@ -560,28 +558,50 @@
private replyDialogResizeObserver?: ResizeObserver;
- override keyboardShortcuts() {
- return {
- [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
- [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
- [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
- [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
- [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
- [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
- [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
- [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
- [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
- [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
- [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
- [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
- [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
- [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
- [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
- [Shortcut.OPEN_SUBMIT_DIALOG]: '_handleOpenSubmitDialog',
- [Shortcut.TOGGLE_ATTENTION_SET]: '_handleToggleAttentionSet',
- };
+ override keyboardShortcuts(): ShortcutListener[] {
+ return [
+ listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
+ listen(Shortcut.EMOJI_DROPDOWN, _ => {}), // docOnly
+ listen(Shortcut.REFRESH_CHANGE, _ => fireReload(this, true)),
+ listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
+ listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
+ this._handleOpenDownloadDialog()
+ ),
+ listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
+ listen(Shortcut.TOGGLE_CHANGE_STAR, e => {
+ if (this._throttledToggleChangeStar) {
+ this._throttledToggleChangeStar(e);
+ }
+ }),
+ listen(Shortcut.UP_TO_DASHBOARD, _ => this._determinePageBack()),
+ listen(Shortcut.EXPAND_ALL_MESSAGES, _ =>
+ this._handleExpandAllMessages()
+ ),
+ listen(Shortcut.COLLAPSE_ALL_MESSAGES, _ =>
+ this._handleCollapseAllMessages()
+ ),
+ listen(Shortcut.OPEN_DIFF_PREFS, _ =>
+ this._handleOpenDiffPrefsShortcut()
+ ),
+ listen(Shortcut.EDIT_TOPIC, _ => this.$.metadata.editTopic()),
+ listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
+ listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
+ this._handleDiffAgainstLatest()
+ ),
+ listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+ this._handleDiffBaseAgainstLeft()
+ ),
+ listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+ this._handleDiffRightAgainstLatest()
+ ),
+ listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+ this._handleDiffBaseAgainstLatest()
+ ),
+ listen(Shortcut.OPEN_SUBMIT_DIALOG, _ => this._handleOpenSubmitDialog()),
+ listen(Shortcut.TOGGLE_ATTENTION_SET, _ =>
+ this._handleToggleAttentionSet()
+ ),
+ ];
}
disconnected$ = new Subject();
@@ -592,6 +612,10 @@
private lastStarredTimestamp?: number;
+ private readonly userService = appContext.userService;
+
+ private diffViewMode?: DiffViewMode;
+
override ready() {
super.ready();
aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
@@ -603,6 +627,11 @@
drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
this._diffDrafts = {...drafts};
});
+ preferenceDiffViewMode$
+ .pipe(takeUntil(this.disconnected$))
+ .subscribe(diffViewMode => {
+ this.diffViewMode = diffViewMode;
+ });
changeComments$
.pipe(takeUntil(this.disconnected$))
.subscribe(changeComments => {
@@ -632,8 +661,8 @@
override connectedCallback() {
super.connectedCallback();
- this._throttledToggleChangeStar = throttleWrap<IronKeyboardEvent>(e =>
- this._handleToggleChangeStar(e)
+ this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+ this._handleToggleChangeStar()
);
this._getServerConfig().then(config => {
this._serverConfig = config;
@@ -647,7 +676,6 @@
this._account = acct;
});
}
- this._setDiffViewMode();
});
this.replyDialogResizeObserver = new ResizeObserver(() =>
@@ -716,24 +744,6 @@
return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
}
- _setDiffViewMode(opt_reset?: boolean) {
- if (!opt_reset && this.viewState.diffViewMode) {
- return;
- }
-
- return this._getPreferences()
- .then(prefs => {
- if (!this.viewState.diffMode && prefs) {
- this.set('viewState.diffMode', prefs.default_diff_view);
- }
- })
- .then(() => {
- if (!this.viewState.diffMode) {
- this.set('viewState.diffMode', 'SIDE_BY_SIDE');
- }
- });
- }
-
_onOpenFixPreview(e: OpenFixPreviewEvent) {
this.$.applyFixDialog.open(e);
}
@@ -742,16 +752,13 @@
if (e.detail.fixApplied) fireReload(this);
}
- _handleToggleDiffMode(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
- this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+ _handleToggleDiffMode() {
+ if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
+ this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
} else {
- this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+ this.userService.updatePreferences({
+ diff_view: DiffViewMode.SIDE_BY_SIDE,
+ });
}
}
@@ -1402,9 +1409,6 @@
!!this.viewState.changeNum &&
this.viewState.changeNum !== this._changeNum
) {
- // Reset the diff mode to null when navigating from one change to
- // another, so that the user's preference is restored.
- this._setDiffViewMode(true);
this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
}
this.set('viewState.changeNum', this._changeNum);
@@ -1486,52 +1490,22 @@
return label;
}
- _handleOpenReplyDialog(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
+ _handleOpenReplyDialog() {
this._getLoggedIn().then(isLoggedIn => {
if (!isLoggedIn) {
fireEvent(this, 'show-auth-required');
return;
}
-
- e.preventDefault();
this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
});
}
- _handleOpenDownloadDialogShortcut(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- this._handleOpenDownloadDialog();
- }
-
- _handleEditTopic(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- this.$.metadata.editTopic();
- }
-
- _handleOpenSubmitDialog(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || !this._submitEnabled) {
- return;
- }
-
- e.preventDefault();
+ _handleOpenSubmitDialog() {
+ if (!this._submitEnabled) return;
this.$.actions.showSubmitDialog();
}
- _handleToggleAttentionSet(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
+ _handleToggleAttentionSet() {
if (!this._change || !this._account?._account_id) return;
if (!this._loggedIn || !isInvolved(this._change, this._account)) return;
if (!this._change.attention_set) this._change.attention_set = {};
@@ -1570,10 +1544,7 @@
this._change = {...this._change};
}
- _handleDiffAgainstBase(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
+ _handleDiffAgainstBase() {
assertIsDefined(this._change, '_change');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
@@ -1584,10 +1555,7 @@
GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
}
- _handleDiffBaseAgainstLeft(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
+ _handleDiffBaseAgainstLeft() {
assertIsDefined(this._change, '_change');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
@@ -1598,10 +1566,7 @@
GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
}
- _handleDiffAgainstLatest(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
+ _handleDiffAgainstLatest() {
assertIsDefined(this._change, '_change');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
@@ -1617,10 +1582,7 @@
);
}
- _handleDiffRightAgainstLatest(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
+ _handleDiffRightAgainstLatest() {
assertIsDefined(this._change, '_change');
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
if (!this._patchRange)
@@ -1636,10 +1598,7 @@
);
}
- _handleDiffBaseAgainstLatest(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
+ _handleDiffBaseAgainstLatest() {
assertIsDefined(this._change, '_change');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
@@ -1654,59 +1613,24 @@
GerritNav.navigateToChange(this._change, latestPatchNum);
}
- _handleRefreshChange(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
- e.preventDefault();
- fireReload(this, true);
- }
-
- _handleToggleChangeStar(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
- e.preventDefault();
+ _handleToggleChangeStar() {
this.$.changeStar.toggleStar();
}
- _handleUpToDashboard(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- this._determinePageBack();
- }
-
- _handleExpandAllMessages(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _handleExpandAllMessages() {
if (this.messagesList) {
this.messagesList.handleExpandCollapse(true);
}
}
- _handleCollapseAllMessages(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _handleCollapseAllMessages() {
if (this.messagesList) {
this.messagesList.handleExpandCollapse(false);
}
}
- _handleOpenDiffPrefsShortcut(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
+ _handleOpenDiffPrefsShortcut() {
if (!this._loggedIn) return;
- e.preventDefault();
this.$.fileList.openDiffPrefs();
}
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 0b77bc7..d42fc17 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
@@ -533,7 +533,6 @@
server-config="[[_serverConfig]]"
shown-file-count="[[_shownFileCount]]"
diff-prefs="[[_diffPrefs]]"
- diff-view-mode="{{viewState.diffMode}}"
patch-num="{{_patchRange.patchNum}}"
base-patch-num="{{_patchRange.basePatchNum}}"
files-expanded="[[_filesExpanded]]"
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 e508c63..5fcc3b0 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
@@ -26,6 +26,7 @@
HttpMethod,
MessageTag,
PrimaryTab,
+ createDefaultPreferences,
} from '../../../constants/constants';
import {GrEditConstants} from '../../edit/gr-edit-constants';
import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -35,7 +36,7 @@
import {EventType, PluginApi} from '../../../api/plugin';
import 'lodash/lodash';
-import {mockPromise, stubRestApi} from '../../../test/test-utils';
+import {mockPromise, stubRestApi, stubUsers} from '../../../test/test-utils';
import {
createAppElementChangeViewParams,
createApproval,
@@ -82,23 +83,19 @@
} from '../../../types/common';
import {
pressAndReleaseKeyOn,
- keyUpOn,
tap,
} from '@polymer/iron-test-helpers/mock-interactions';
import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
import {AppElementChangeViewParams} from '../../gr-app-types';
import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {
- IronKeyboardEvent,
- IronKeyboardEventDetail,
-} from '../../../types/events';
import {CommentThread, UIRobot} 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 {appContext} from '../../../services/app-context';
import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
+import {_testOnly_setState} from '../../../services/user/user-model';
const pluginApi = _testOnly_initGerritPluginApi();
const fixture = fixtureFromElement('gr-change-view');
@@ -403,7 +400,7 @@
patchNum: 3 as RevisionPatchSetNum,
basePatchNum: 1 as BasePatchSetNum,
};
- element._handleDiffAgainstBase(new CustomEvent('') as IronKeyboardEvent);
+ element._handleDiffAgainstBase();
assert(navigateToChangeStub.called);
const args = navigateToChangeStub.getCall(0).args;
assert.equal(args[0], element._change);
@@ -419,7 +416,7 @@
basePatchNum: 1 as BasePatchSetNum,
patchNum: 3 as RevisionPatchSetNum,
};
- element._handleDiffAgainstLatest(new CustomEvent('') as IronKeyboardEvent);
+ element._handleDiffAgainstLatest();
assert(navigateToChangeStub.called);
const args = navigateToChangeStub.getCall(0).args;
assert.equal(args[0], element._change);
@@ -436,9 +433,7 @@
patchNum: 3 as RevisionPatchSetNum,
basePatchNum: 1 as BasePatchSetNum,
};
- element._handleDiffBaseAgainstLeft(
- new CustomEvent('') as IronKeyboardEvent
- );
+ element._handleDiffBaseAgainstLeft();
assert(navigateToChangeStub.called);
const args = navigateToChangeStub.getCall(0).args;
assert.equal(args[0], element._change);
@@ -454,9 +449,7 @@
basePatchNum: 1 as BasePatchSetNum,
patchNum: 3 as RevisionPatchSetNum,
};
- element._handleDiffRightAgainstLatest(
- new CustomEvent('') as IronKeyboardEvent
- );
+ element._handleDiffRightAgainstLatest();
assert(navigateToChangeStub.called);
const args = navigateToChangeStub.getCall(0).args;
assert.equal(args[1], 10 as PatchSetNum);
@@ -472,9 +465,7 @@
basePatchNum: 1 as BasePatchSetNum,
patchNum: 3 as RevisionPatchSetNum,
};
- element._handleDiffBaseAgainstLatest(
- new CustomEvent('') as IronKeyboardEvent
- );
+ element._handleDiffBaseAgainstLatest();
assert(navigateToChangeStub.called);
const args = navigateToChangeStub.getCall(0).args;
assert.equal(args[1], 10 as PatchSetNum);
@@ -501,11 +492,11 @@
assert.isNotOk(element._change.attention_set);
await element._getLoggedIn();
await element.restApiService.getAccount();
- element._handleToggleAttentionSet(new CustomEvent('') as IronKeyboardEvent);
+ element._handleToggleAttentionSet();
assert.isTrue(addToAttentionSetStub.called);
assert.isFalse(removeFromAttentionSetStub.called);
- element._handleToggleAttentionSet(new CustomEvent('') as IronKeyboardEvent);
+ element._handleToggleAttentionSet();
assert.isTrue(removeFromAttentionSetStub.called);
});
@@ -650,7 +641,7 @@
sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
const loggedInErrorSpy = sinon.spy();
element.addEventListener('show-auth-required', loggedInErrorSpy);
- keyUpOn(element, 65, null, 'a');
+ pressAndReleaseKeyOn(element, 65, null, 'a');
await flush();
assert.isFalse(element.$.replyOverlay.opened);
assert.isTrue(loggedInErrorSpy.called);
@@ -683,7 +674,7 @@
const openSpy = sinon.spy(element, '_openReplyDialog');
- keyUpOn(element, 65, null, 'a');
+ pressAndReleaseKeyOn(element, 65, null, 'a');
await flush();
assert.isTrue(element.$.replyOverlay.opened);
element.$.replyOverlay.close();
@@ -796,7 +787,7 @@
const stub = sinon
.stub(element.$.downloadOverlay, 'open')
.returns(Promise.resolve());
- keyUpOn(element, 68, null, 'd');
+ pressAndReleaseKeyOn(element, 68, null, 'd');
assert.isTrue(stub.called);
});
@@ -814,23 +805,30 @@
assert.isTrue(stub.called);
});
- test('m should toggle diff mode', () => {
- const setModeStub = sinon.stub(
- element.$.fileListHeader,
- 'setDiffViewMode'
+ test('m should toggle diff mode', async () => {
+ const updatePreferencesStub = stubUsers('updatePreferences');
+ await flush();
+
+ const prefs = {
+ ...createDefaultPreferences(),
+ diff_view: DiffViewMode.SIDE_BY_SIDE,
+ };
+ _testOnly_setState({preferences: prefs});
+ element._handleToggleDiffMode();
+ assert.isTrue(
+ updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
);
- const e = new CustomEvent<IronKeyboardEventDetail>('keydown', {
- detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
- });
- flush();
- element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
- element._handleToggleDiffMode(e);
- assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));
-
- element.viewState.diffMode = DiffViewMode.UNIFIED;
- element._handleToggleDiffMode(e);
- assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
+ const newPrefs = {
+ ...createDefaultPreferences(),
+ diff_view: DiffViewMode.UNIFIED,
+ };
+ _testOnly_setState({preferences: newPrefs});
+ await flush();
+ element._handleToggleDiffMode();
+ assert.isTrue(
+ updatePreferencesStub.calledWith({diff_view: DiffViewMode.SIDE_BY_SIDE})
+ );
});
});
@@ -1292,52 +1290,6 @@
assert.equal(element._numFilesShown, 200);
});
- test('_setDiffViewMode is called with reset when new change is loaded', () => {
- const setDiffViewModeStub = sinon.stub(element, '_setDiffViewMode');
- element.viewState = {changeNum: 1 as NumericChangeId};
- element._changeNum = 2 as NumericChangeId;
- element._resetFileListViewState();
- assert.isTrue(setDiffViewModeStub.calledWithExactly(true));
- });
-
- test('diffViewMode is propagated from file list header', () => {
- element.viewState = {diffMode: DiffViewMode.UNIFIED};
- element.$.fileListHeader.diffViewMode = DiffViewMode.SIDE_BY_SIDE;
- assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
- });
-
- test('diffMode defaults to side by side without preferences', async () => {
- stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
- // No user prefs or diff view mode set.
-
- await element._setDiffViewMode()!;
- assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
- });
-
- test('diffMode defaults to preference when not already set', async () => {
- stubRestApi('getPreferences').returns(
- Promise.resolve({
- ...createPreferences(),
- default_diff_view: DiffViewMode.UNIFIED,
- })
- );
-
- await element._setDiffViewMode()!;
- assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
- });
-
- test('existing diffMode overrides preference', async () => {
- element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
- stubRestApi('getPreferences').returns(
- Promise.resolve({
- ...createPreferences(),
- default_diff_view: DiffViewMode.UNIFIED,
- })
- );
- await element._setDiffViewMode()!;
- assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
- });
-
test('don’t reload entire page when patchRange changes', async () => {
const reloadStub = sinon
.stub(element, 'loadData')
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index 07da53f..df537e0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -19,9 +19,9 @@
import '../../../styles/shared-styles';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {customElement, property} from '@polymer/decorators';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
export interface GrConfirmAbandonDialog {
$: {
@@ -35,11 +35,8 @@
}
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends base {
+export class GrConfirmAbandonDialog extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -59,20 +56,33 @@
@property({type: String})
message = '';
- get keyBindings() {
- return {
- 'ctrl+enter meta+enter': '_handleEnterKey',
- };
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+ this._confirm()
+ )
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+ this._confirm()
+ )
+ );
}
resetFocus() {
this.$.messageInput.textarea.focus();
}
- _handleEnterKey() {
- this._confirm();
- }
-
_handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 8e6521d..b3bbc8a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -19,17 +19,14 @@
import '../../shared/gr-dialog/gr-dialog';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-confirm-move-dialog_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {customElement, property} from '@polymer/decorators';
import {BranchName, RepoName} from '../../../types/common';
import {appContext} from '../../../services/app-context';
import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
const SUGGESTIONS_LIMIT = 15;
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
// This is used to make sure 'branch'
// can be typed as BranchName.
export interface GrConfirmMoveDialog {
@@ -39,7 +36,7 @@
}
@customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends base {
+export class GrConfirmMoveDialog extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -68,10 +65,27 @@
@property({type: Object})
_query?: (input: string) => Promise<{name: BranchName}[]>;
- get keyBindings() {
- return {
- 'ctrl+enter meta+enter': '_handleConfirmTap',
- };
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, e =>
+ this._handleConfirmTap(e)
+ )
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, e =>
+ this._handleConfirmTap(e)
+ )
+ );
}
private readonly restApiService = appContext.restApiService;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index d0865c9..92f4a87 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -31,8 +31,8 @@
import {GrButton} from '../../shared/gr-button/gr-button';
import {hasOwnProperty} from '../../../utils/common-util';
import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {addShortcut} from '../../../utils/dom-util';
export interface GrDownloadDialog {
$: {
@@ -42,11 +42,8 @@
};
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-download-dialog')
-export class GrDownloadDialog extends base {
+export class GrDownloadDialog extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -69,14 +66,22 @@
@property({type: String})
_selectedScheme?: string;
- get keyBindings() {
- return {
- 1: '_handleNumberKey',
- 2: '_handleNumberKey',
- 3: '_handleNumberKey',
- 4: '_handleNumberKey',
- 5: '_handleNumberKey',
- };
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ for (const key of ['1', '2', '3', '4', '5']) {
+ this.cleanups.push(
+ addShortcut(this, {key}, e => this._handleNumberKey(e))
+ );
+ }
}
@computed('change', 'patchNum')
@@ -98,8 +103,8 @@
return [];
}
- _handleNumberKey(e: CustomEvent) {
- const index = Number(e.detail.key) - 1;
+ _handleNumberKey(e: KeyboardEvent) {
+ const index = Number(e.key) - 1;
const commands = this._computeDownloadCommands(
this.change,
this.patchNum,
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 50bb665..1b44e35 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -41,11 +41,9 @@
import {DiffPreferencesInfo} from '../../../types/diff';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
-import {DiffViewMode} from '../../../constants/constants';
import {GrButton} from '../../shared/gr-button/gr-button';
import {fireEvent} from '../../../utils/event-util';
import {
- KeyboardShortcutMixin,
Shortcut,
ShortcutSection,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -65,11 +63,8 @@
};
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-file-list-header')
-export class GrFileListHeader extends base {
+export class GrFileListHeader extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -126,9 +121,6 @@
@property({type: Object})
diffPrefs?: DiffPreferencesInfo;
- @property({type: String, notify: true})
- diffViewMode?: DiffViewMode;
-
@property({type: String})
patchNum?: PatchSetNum;
@@ -148,10 +140,6 @@
private readonly shortcuts = appContext.shortcutsService;
- setDiffViewMode(mode: DiffViewMode) {
- this.$.modeSelect.setMode(mode);
- }
-
_expandAllDiffs() {
fireEvent(this, 'expand-diffs');
}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 73d0819..5a85531 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -169,7 +169,6 @@
<span class="fileViewActionsLabel">Diff view:</span>
<gr-diff-mode-selector
id="modeSelect"
- mode="{{diffViewMode}}"
save-on-change="[[loggedIn]]"
></gr-diff-mode-selector>
<span
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 74c079b..5506bc7 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
@@ -36,6 +36,7 @@
import {
KeyboardShortcutMixin,
Shortcut,
+ ShortcutListener,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {FilesExpandedState} from '../gr-file-list-constants';
import {pluralize} from '../../../utils/string-util';
@@ -49,9 +50,10 @@
SpecialFilePath,
} from '../../../constants/constants';
import {
+ addGlobalShortcut,
+ addShortcut,
descendedFromClass,
- isShiftPressed,
- modifierPressed,
+ Key,
toggleClass,
} from '../../../utils/dom-util';
import {
@@ -78,7 +80,6 @@
import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
-import {IronKeyboardEvent} from '../../../types/events';
import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
import {Timing} from '../../../constants/reporting';
import {RevisionInfo} from '../../shared/revision-info/revision-info';
@@ -86,6 +87,8 @@
import {changeComments$} from '../../../services/comments/comments-model';
import {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {diffViewMode$} from '../../../services/browser/browser-model';
export const DEFAULT_NUM_FILES_SHOWN = 200;
@@ -198,9 +201,6 @@
selectedIndex = -1;
@property({type: Object})
- keyEventTarget = document.body;
-
- @property({type: Object})
change?: ParsedChangeInfo;
@property({type: String, notify: true, observer: '_updateDiffPreferences'})
@@ -319,51 +319,56 @@
disconnected$ = new Subject();
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
- override keyboardShortcuts() {
- return {
- [Shortcut.LEFT_PANE]: '_handleLeftPane',
- [Shortcut.RIGHT_PANE]: '_handleRightPane',
- [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
- [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
- [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
- '_handleToggleHideAllCommentThreads',
- [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
- [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
- [Shortcut.NEXT_LINE]: '_handleCursorNext',
- [Shortcut.PREV_LINE]: '_handleCursorPrev',
- [Shortcut.NEW_COMMENT]: '_handleNewComment',
- [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
- [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
- [Shortcut.OPEN_FILE]: '_handleOpenFile',
- [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
- [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
- [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
- [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
- // Final two are actually handled by gr-comment-thread.
- [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
- [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
- };
+ override keyboardShortcuts(): ShortcutListener[] {
+ return [
+ listen(Shortcut.LEFT_PANE, _ => this._handleLeftPane()),
+ listen(Shortcut.RIGHT_PANE, _ => this._handleRightPane()),
+ listen(Shortcut.TOGGLE_INLINE_DIFF, _ => this._handleToggleInlineDiff()),
+ listen(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ => this._toggleInlineDiffs()),
+ listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+ toggleClass(this, 'hideComments')
+ ),
+ listen(Shortcut.CURSOR_NEXT_FILE, e => this._handleCursorNext(e)),
+ listen(Shortcut.CURSOR_PREV_FILE, e => this._handleCursorPrev(e)),
+ // This is already been taken care of by CURSOR_NEXT_FILE above. The two
+ // shortcuts share the same bindings. It depends on whether all files
+ // are expanded whether the cursor moves to the next file or line.
+ listen(Shortcut.NEXT_LINE, _ => {}), // docOnly
+ // This is already been taken care of by CURSOR_PREV_FILE above. The two
+ // shortcuts share the same bindings. It depends on whether all files
+ // are expanded whether the cursor moves to the previous file or line.
+ listen(Shortcut.PREV_LINE, _ => {}), // docOnly
+ listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
+ listen(Shortcut.OPEN_LAST_FILE, _ =>
+ this._openSelectedFile(this._files.length - 1)
+ ),
+ listen(Shortcut.OPEN_FIRST_FILE, _ => this._openSelectedFile(0)),
+ listen(Shortcut.OPEN_FILE, _ => this.handleOpenFile()),
+ listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
+ listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
+ listen(Shortcut.NEXT_COMMENT_THREAD, _ => this._handleNextComment()),
+ listen(Shortcut.PREV_COMMENT_THREAD, _ => this._handlePrevComment()),
+ listen(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
+ this._handleToggleFileReviewed()
+ ),
+ listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
+ listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
+ listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+ ];
}
private fileCursor = new GrCursorManager();
private diffCursor = new GrDiffCursor();
- private readonly shortcuts = appContext.shortcutsService;
-
constructor() {
super();
this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
this.fileCursor.cursorTargetClass = 'selected';
this.fileCursor.focusOnMove = true;
- this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
}
override connectedCallback() {
@@ -373,6 +378,10 @@
.subscribe(changeComments => {
this.changeComments = changeComments;
});
+ diffViewMode$
+ .pipe(takeUntil(this.disconnected$))
+ .subscribe(diffView => (this.diffViewMode = diffView));
+
getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
@@ -415,6 +424,12 @@
this.reporting.error(new Error('dynamic header/content mismatch'));
}
});
+ this.cleanups.push(
+ addGlobalShortcut({key: Key.ESC}, _ => this._handleEscKey()),
+ addShortcut(this, {key: Key.ENTER}, _ => this.handleOpenFile(), {
+ shouldSuppress: true,
+ })
+ );
}
override disconnectedCallback() {
@@ -423,20 +438,11 @@
this.fileCursor.unsetCursor();
this._cancelDiffs();
this.loadingTask?.cancel();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
super.disconnectedCallback();
}
- /**
- * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
- * events must be scoped to a component level (e.g. `enter`) in order to not
- * override native browser functionality.
- *
- * Context: Issue 7277
- */
- _scopedKeydownHandler(e: KeyboardEvent) {
- if (e.keyCode === 13) this.handleOpenFile(e);
- }
-
reload() {
if (!this.changeNum || !this.patchRange?.patchNum) {
return Promise.resolve();
@@ -881,196 +887,84 @@
return fileData;
}
- _handleLeftPane(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
+ _handleLeftPane() {
+ if (this._noDiffsExpanded()) return;
this.diffCursor.moveLeft();
}
- _handleRightPane(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
+ _handleRightPane() {
+ if (this._noDiffsExpanded()) return;
this.diffCursor.moveRight();
}
- _handleToggleInlineDiff(e: IronKeyboardEvent) {
- if (
- this.shortcuts.shouldSuppress(e) ||
- this.shortcuts.modifierPressed(e) ||
- e.detail?.keyboardEvent?.repeat ||
- this.fileCursor.index === -1
- ) {
- return;
- }
-
- e.preventDefault();
+ _handleToggleInlineDiff() {
+ if (this.fileCursor.index === -1) return;
this._toggleFileExpandedByIndex(this.fileCursor.index);
}
- _handleToggleAllInlineDiffs(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || e.detail?.keyboardEvent?.repeat) {
- return;
- }
-
- e.preventDefault();
- this._toggleInlineDiffs();
- }
-
- _handleToggleHideAllCommentThreads(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- toggleClass(this, 'hideComments');
- }
-
- _handleCursorNext(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
+ _handleCursorNext(e: KeyboardEvent) {
if (this.filesExpanded === FilesExpandedState.ALL) {
- e.preventDefault();
this.diffCursor.moveDown();
this._displayLine = true;
} else {
- // Down key
- if (e.detail.keyboardEvent.keyCode === 40) {
- return;
- }
- e.preventDefault();
+ if (e.key === Key.DOWN) return;
this.fileCursor.next({circular: true});
this.selectedIndex = this.fileCursor.index;
}
}
- _handleCursorPrev(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
-
+ _handleCursorPrev(e: KeyboardEvent) {
if (this.filesExpanded === FilesExpandedState.ALL) {
- e.preventDefault();
this.diffCursor.moveUp();
this._displayLine = true;
} else {
- // Up key
- if (e.detail.keyboardEvent.keyCode === 38) {
- return;
- }
- e.preventDefault();
+ if (e.key === Key.UP) return;
this.fileCursor.previous({circular: true});
this.selectedIndex = this.fileCursor.index;
}
}
- _handleNewComment(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
- e.preventDefault();
+ _handleNewComment() {
this.classList.remove('hideComments');
this.diffCursor.createCommentInPlace();
}
- _handleOpenLastFile(e: IronKeyboardEvent) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shortcuts.shouldSuppress(e) || e.detail.keyboardEvent.metaKey) {
- return;
- }
-
- e.preventDefault();
- this._openSelectedFile(this._files.length - 1);
- }
-
- _handleOpenFirstFile(e: IronKeyboardEvent) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shortcuts.shouldSuppress(e) || e.detail.keyboardEvent.metaKey) {
- return;
- }
-
- e.preventDefault();
- this._openSelectedFile(0);
- }
-
- _handleOpenFile(e: IronKeyboardEvent) {
- if (this.shortcuts.modifierPressed(e)) return;
- this.handleOpenFile(e.detail.keyboardEvent);
- }
-
- handleOpenFile(e: KeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || modifierPressed(e)) {
- return;
- }
- e.preventDefault();
-
+ handleOpenFile() {
if (this.filesExpanded === FilesExpandedState.ALL) {
this._openCursorFile();
return;
}
-
this._openSelectedFile();
}
- _handleNextChunk(e: IronKeyboardEvent) {
- if (
- this.shortcuts.shouldSuppress(e) ||
- (this.shortcuts.modifierPressed(e) && !isShiftPressed(e)) ||
- this._noDiffsExpanded()
- ) {
- return;
- }
-
- e.preventDefault();
- if (isShiftPressed(e)) {
- this.diffCursor.moveToNextCommentThread();
- } else {
- this.diffCursor.moveToNextChunk();
- }
+ _handleNextChunk() {
+ if (this._noDiffsExpanded()) return;
+ this.diffCursor.moveToNextChunk();
}
- _handlePrevChunk(e: IronKeyboardEvent) {
- if (
- this.shortcuts.shouldSuppress(e) ||
- (this.shortcuts.modifierPressed(e) && !isShiftPressed(e)) ||
- this._noDiffsExpanded()
- ) {
- return;
- }
-
- e.preventDefault();
- if (isShiftPressed(e)) {
- this.diffCursor.moveToPreviousCommentThread();
- } else {
- this.diffCursor.moveToPreviousChunk();
- }
+ _handleNextComment() {
+ if (this._noDiffsExpanded()) return;
+ this.diffCursor.moveToNextCommentThread();
}
- _handleToggleFileReviewed(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
+ _handlePrevChunk() {
+ if (this._noDiffsExpanded()) return;
+ this.diffCursor.moveToPreviousChunk();
+ }
- e.preventDefault();
+ _handlePrevComment() {
+ if (this._noDiffsExpanded()) return;
+ this.diffCursor.moveToPreviousCommentThread();
+ }
+
+ _handleToggleFileReviewed() {
if (!this._files[this.fileCursor.index]) {
return;
}
this._reviewFile(this._files[this.fileCursor.index].__path);
}
- _handleToggleLeftPane(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) {
- return;
- }
-
- e.preventDefault();
+ _handleToggleLeftPane() {
this._forEachDiff(diff => {
diff.toggleLeftDiff();
});
@@ -1542,11 +1436,7 @@
return undefined;
}
- _handleEscKey(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
- return;
- }
- e.preventDefault();
+ _handleEscKey() {
this._displayLine = false;
}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index f7be36b..e8371e3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -643,7 +643,6 @@
prefs="[[diffPrefs]]"
project-name="[[change.project]]"
no-render-on-prefs-change=""
- view-mode="[[diffViewMode]]"
></gr-diff-host>
</template>
</div>
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 062d6a2..0db7690 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
@@ -27,11 +27,11 @@
import {runA11yAudit} from '../../../test/a11y-test-utils.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {
- stubRestApi,
- spyRestApi,
listenOnce,
mockPromise,
query,
+ spyRestApi,
+ stubRestApi,
} from '../../../test/test-utils.js';
import {EditPatchSetNum} from '../../../types/common.js';
import {createCommentThreads} from '../../../utils/comment-util.js';
@@ -470,7 +470,7 @@
// https://github.com/sinonjs/sinon/issues/781
const diffsStub = sinon.stub(element, 'diffs')
.get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
- MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+ MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
assert.isTrue(toggleLeftDiffStub.calledOnce);
diffsStub.restore();
});
@@ -486,7 +486,7 @@
assert.isFalse(items[1].classList.contains('selected'));
assert.isFalse(items[2].classList.contains('selected'));
// j with a modifier should not move the cursor.
- MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+ MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'J');
assert.equal(element.fileCursor.index, 0);
// down should not move the cursor.
MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
@@ -502,7 +502,7 @@
assert.equal(element.selectedIndex, 2);
// k with a modifier should not move the cursor.
- MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+ MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'K');
assert.equal(element.fileCursor.index, 2);
// up should not move the cursor.
@@ -560,7 +560,7 @@
assert.equal(element._expandedFiles.length, 1);
assert.equal(element._expandedFiles[0].path, paths[1]);
- MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+ MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
flush();
assert.equal(element.diffs.length, paths.length);
assert.equal(element._expandedFiles.length, paths.length);
@@ -569,7 +569,7 @@
}
// since _expandedFilesChanged is stubbed
element.filesExpanded = FilesExpandedState.ALL;
- MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+ MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
flush();
assert.equal(element.diffs.length, 0);
assert.equal(element._expandedFiles.length, 0);
@@ -584,16 +584,16 @@
assert.equal(getNumReviewed(), 0);
// Press the review key to toggle it (set the flag).
- MockInteractions.keyUpOn(element, 82, null, 'r');
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
flush();
assert.equal(getNumReviewed(), 1);
// Press the review key to toggle it (clear the flag).
- MockInteractions.keyUpOn(element, 82, null, 'r');
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
assert.equal(getNumReviewed(), 0);
});
- suite('_handleOpenFile', () => {
+ suite('handleOpenFile', () => {
let interact;
setup(() => {
@@ -605,14 +605,7 @@
openCursorStub.reset();
openSelectedStub.reset();
expandStub.reset();
-
- const keyboardEvent = new KeyboardEvent('keydown');
- const e = new CustomEvent('keydown', {
- detail: {keyboardEvent, key: 'x'},
- });
- sinon.stub(keyboardEvent, 'preventDefault');
- element._handleOpenFile(e);
- assert.isTrue(keyboardEvent.preventDefault.called);
+ element.handleOpenFile();
const result = {};
if (openCursorStub.called) {
result.opened_cursor = true;
@@ -653,16 +646,20 @@
sinon.stub(element, '_noDiffsExpanded')
.callsFake(() => noDiffsExpanded);
- MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+ MockInteractions.pressAndReleaseKeyOn(
+ element, 73, 'shift', 'ArrowLeft');
assert.isFalse(moveLeftStub.called);
- MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+ MockInteractions.pressAndReleaseKeyOn(
+ element, 73, 'shift', 'ArrowRight');
assert.isFalse(moveRightStub.called);
noDiffsExpanded = false;
- MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+ MockInteractions.pressAndReleaseKeyOn(
+ element, 73, 'shift', 'ArrowLeft');
assert.isTrue(moveLeftStub.called);
- MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+ MockInteractions.pressAndReleaseKeyOn(
+ element, 73, 'shift', 'ArrowRight');
assert.isTrue(moveRightStub.called);
});
});
@@ -823,10 +820,8 @@
MockInteractions.tap(row);
flush();
- const diffDisplay = element.diffs[0];
- element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+ element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
element.set('diffViewMode', 'UNIFIED_DIFF');
- assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
assert.isTrue(element._updateDiffPreferences.called);
});
@@ -1590,7 +1585,7 @@
});
test('cursor with toggle all files', async () => {
- MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+ MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
await flush();
const diffs = await renderAndGetNewDiffs(0);
@@ -1620,14 +1615,12 @@
});
suite('n key presses', () => {
- let nKeySpy;
let nextCommentStub;
let nextChunkStub;
let fileRows;
setup(() => {
sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
- nKeySpy = sinon.spy(element, '_handleNextChunk');
nextCommentStub = sinon.stub(element.diffCursor,
'moveToNextCommentThread');
nextChunkStub = sinon.stub(element.diffCursor,
@@ -1636,58 +1629,40 @@
element.root.querySelectorAll('.row:not(.header-row)');
});
- test('n key with some files expanded and no shift key', async () => {
+ test('n key with some files expanded', async () => {
MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
await flush();
+ assert.equal(element.filesExpanded, FilesExpandedState.SOME);
- // Handle N key should return before calling diff cursor functions.
MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
- assert.isTrue(nKeySpy.called);
- assert.isFalse(nextCommentStub.called);
-
- // This is also called in diffCursor.moveToFirstChunk.
- assert.equal(nextChunkStub.callCount, 1);
- assert.equal(element.filesExpanded, 'some');
+ assert.isTrue(nextChunkStub.calledOnce);
});
- test('n key with some files expanded and shift key', async () => {
+ test('N key with some files expanded', async () => {
MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
await flush();
- assert.equal(nextChunkStub.callCount, 0);
+ assert.equal(element.filesExpanded, FilesExpandedState.SOME);
- MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
- assert.isTrue(nKeySpy.called);
- assert.isTrue(nextCommentStub.called);
-
- // This is also called in diffCursor.moveToFirstChunk.
- assert.equal(nextChunkStub.callCount, 0);
- assert.equal(element.filesExpanded, 'some');
+ MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+ assert.isTrue(nextCommentStub.calledOnce);
});
- test('n key without all files expanded and shift key', async () => {
- MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+ test('n key with all files expanded', async () => {
+ MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
await flush();
+ assert.equal(element.filesExpanded, FilesExpandedState.ALL);
MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
- assert.isTrue(nKeySpy.called);
- assert.isFalse(nextCommentStub.called);
-
- // This is also called in diffCursor.moveToFirstChunk.
- assert.equal(nextChunkStub.callCount, 1);
- assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+ assert.isTrue(nextChunkStub.calledOnce);
});
- test('n key without all files expanded and no shift key', async () => {
- MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+ test('N key with all files expanded', async () => {
+ MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
await flush();
-
- MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
- assert.isTrue(nKeySpy.called);
- assert.isTrue(nextCommentStub.called);
-
- // This is also called in diffCursor.moveToFirstChunk.
- assert.equal(nextChunkStub.callCount, 0);
assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+ MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+ assert.isTrue(nextCommentStub.called);
});
});
@@ -1708,26 +1683,17 @@
test('_displayLine', () => {
element.filesExpanded = FilesExpandedState.ALL;
- const mockEvent = {
- preventDefault() {},
- composedPath() { return []; },
- detail: {
- keyboardEvent: {
- composedPath() { return []; },
- },
- },
- };
element._displayLine = false;
- element._handleCursorNext(mockEvent);
+ element._handleCursorNext(new KeyboardEvent('keydown'));
assert.isTrue(element._displayLine);
element._displayLine = false;
- element._handleCursorPrev(mockEvent);
+ element._handleCursorPrev(new KeyboardEvent('keydown'));
assert.isTrue(element._displayLine);
element._displayLine = true;
- element._handleEscKey(mockEvent);
+ element._handleEscKey();
assert.isFalse(element._displayLine);
});
@@ -1737,13 +1703,13 @@
const saveReviewStub = sinon.stub(element, '_saveReviewedState');
element.editMode = false;
- MockInteractions.keyUpOn(element, 82, null, 'r');
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
assert.isTrue(saveReviewStub.calledOnce);
element.editMode = true;
await flush();
- MockInteractions.keyUpOn(element, 82, null, 'r');
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
assert.isTrue(saveReviewStub.calledOnce);
});
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
index e21584e..e57a216 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
@@ -53,29 +53,31 @@
);
padding: 0 var(--spacing-m);
}
- gr-tooltip-content.iron-selected > gr-button[vote='max'] {
+ gr-button[vote='max'].iron-selected {
--button-background-color: var(--vote-color-approved);
}
- gr-tooltip-content.iron-selected > gr-button[vote='positive'] {
+ gr-button[vote='positive'].iron-selected {
--button-background-color: var(--vote-color-recommended);
}
- gr-tooltip-content.iron-selected > gr-button[vote='min'] {
+ gr-button[vote='min'].iron-selected {
--button-background-color: var(--vote-color-rejected);
}
- gr-tooltip-content.iron-selected > gr-button[vote='negative'] {
+ gr-button[vote='negative'].iron-selected {
--button-background-color: var(--vote-color-disliked);
}
- gr-tooltip-content.iron-selected > gr-button[vote='neutral'] {
+ gr-button[vote='neutral'].iron-selected {
--button-background-color: var(--vote-color-neutral);
}
- gr-tooltip-content.iron-selected
- > gr-button[vote='positive']::part(paper-button) {
+ gr-button[vote='positive'].iron-selected::part(paper-button) {
border-color: var(--vote-outline-recommended);
}
- gr-tooltip-content.iron-selected
- > gr-button[vote='negative']::part(paper-button) {
+ gr-button[vote='negative'].iron-selected::part(paper-button) {
border-color: var(--vote-outline-disliked);
}
+ gr-button > gr-tooltip-content {
+ margin: 0px -10px;
+ padding: 0px 10px;
+ }
.placeholder {
display: inline-block;
width: 42px;
@@ -118,20 +120,22 @@
aria-labelledby="labelName"
>
<template is="dom-repeat" items="[[_items]]" as="value">
- <gr-tooltip-content
- has-tooltip
+ <gr-button
+ role="radio"
+ vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
data-name$="[[label.name]]"
data-value$="[[value]]"
+ aria-label$="[[value]]"
+ voteChip
>
- <gr-button
- role="radio"
- vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
- voteChip
+ <gr-tooltip-content
+ has-tooltip
+ title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
>
[[value]]
- </gr-button>
- </gr-tooltip-content>
+ </gr-tooltip-content>
+ </gr-button>
</template>
</iron-selector>
<template
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
index 34e959b..51d76b2 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
@@ -101,15 +101,12 @@
const labelsChangedHandler = sinon.stub();
element.addEventListener('labels-changed', labelsChangedHandler);
assert.ok(element.$.labelSelector);
- MockInteractions.tap(element.shadowRoot
- .querySelector(
- 'gr-tooltip-content[data-value="-1"] > gr-button'));
+ MockInteractions.tap(
+ element.shadowRoot.querySelector('gr-button[data-value="-1"]'));
await flush();
assert.strictEqual(element.selectedValue, '-1');
- assert.strictEqual(element.selectedItem
- .textContent.trim(), '-1');
- assert.strictEqual(
- element.$.selectedValueLabel.textContent.trim(), 'bad');
+ assert.strictEqual(element.selectedItem.textContent.trim(), '-1');
+ assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'bad');
const detail = labelsChangedHandler.args[0][0].detail;
assert.equal(detail.name, 'Verified');
assert.equal(detail.value, '-1');
@@ -121,49 +118,48 @@
let index = 0;
const totalItems = 5;
// positive and first position
- assert.equal(element._computeVoteAttribute(value, index,
- totalItems), 'positive');
+ assert.equal(
+ element._computeVoteAttribute(value, index, totalItems), 'positive');
// negative and first position
value = -1;
- assert.equal(element._computeVoteAttribute(value, index,
- totalItems), 'min');
+ assert.equal(
+ element._computeVoteAttribute(value, index, totalItems), 'min');
// negative but not first position
index = 1;
- assert.equal(element._computeVoteAttribute(value, index,
- totalItems), 'negative');
+ assert.equal(
+ element._computeVoteAttribute(value, index, totalItems), 'negative');
// neutral
value = 0;
- assert.equal(element._computeVoteAttribute(value, index,
- totalItems), 'neutral');
+ assert.equal(
+ element._computeVoteAttribute(value, index, totalItems), 'neutral');
// positive but not last position
value = 1;
- assert.equal(element._computeVoteAttribute(value, index,
- totalItems), 'positive');
+ assert.equal(
+ element._computeVoteAttribute(value, index, totalItems), 'positive');
// positive and last position
index = 4;
- assert.equal(element._computeVoteAttribute(value, index,
- totalItems), 'max');
+ assert.equal(
+ element._computeVoteAttribute(value, index, totalItems), 'max');
// negative and last position
value = -1;
- assert.equal(element._computeVoteAttribute(value, index,
- totalItems), 'negative');
+ assert.equal(
+ element._computeVoteAttribute(value, index, totalItems), 'negative');
});
test('correct item is selected', () => {
// 1 should be the value of the selected item
assert.strictEqual(element.$.labelSelector.selected, '+1');
assert.strictEqual(
- element.$.labelSelector.selectedItem
- .textContent.trim(), '+1');
- assert.strictEqual(
- element.$.selectedValueLabel.textContent.trim(), 'good');
+ element.$.labelSelector.selectedItem.textContent.trim(), '+1');
+ assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'good');
checkAriaCheckedValid();
});
test('_computeLabelValue', () => {
- assert.strictEqual(element._computeLabelValue(element.labels,
- element.permittedLabels,
- element.label), '+1');
+ assert.strictEqual(
+ element._computeLabelValue(
+ element.labels, element.permittedLabels, element.label),
+ '+1');
});
test('_computeBlankItems', () => {
@@ -175,18 +171,21 @@
'2': 4,
};
- assert.strictEqual(element._computeBlankItems(element.permittedLabels,
- 'Code-Review').length, 0);
+ assert.strictEqual(
+ element._computeBlankItems(element.permittedLabels, 'Code-Review')
+ .length,
+ 0);
- assert.strictEqual(element._computeBlankItems(element.permittedLabels,
- 'Verified').length, 1);
+ assert.strictEqual(
+ element._computeBlankItems(element.permittedLabels, 'Verified').length,
+ 1);
});
test('labelValues returns no keys', () => {
element.labelValues = {};
- assert.deepEqual(element._computeBlankItems(element.permittedLabels,
- 'Code-Review'), []);
+ assert.deepEqual(
+ element._computeBlankItems(element.permittedLabels, 'Code-Review'), []);
});
test('changes in label score are reflected in the DOM', async () => {
@@ -260,11 +259,8 @@
],
};
await flush();
- assert.strictEqual(element.$.labelSelector
- .items.length, 2);
- assert.strictEqual(
- element.root.querySelectorAll('.placeholder').length,
- 3);
+ assert.strictEqual(element.$.labelSelector.items.length, 2);
+ assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 3);
element.permittedLabels = {
'Code-Review': [
@@ -280,11 +276,8 @@
],
};
await flush();
- assert.strictEqual(element.$.labelSelector
- .items.length, 5);
- assert.strictEqual(
- element.root.querySelectorAll('.placeholder').length,
- 0);
+ assert.strictEqual(element.$.labelSelector.items.length, 5);
+ assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 0);
});
test('default_value', () => {
@@ -344,4 +337,3 @@
assert.isNull(element.selectedValue);
});
});
-
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 cd79bba..e16c073 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
@@ -22,7 +22,6 @@
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-messages-list_html';
import {
- KeyboardShortcutMixin,
Shortcut,
ShortcutSection,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -201,11 +200,8 @@
};
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-messages-list')
-export class GrMessagesList extends base {
+export class GrMessagesList extends PolymerElement {
static get template() {
return htmlTemplate;
}
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 b8c9319..486f37a 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
@@ -16,10 +16,11 @@
*/
import '../../../test/common-test-setup-karma.js';
-import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
import './gr-reply-dialog.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.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');
const pluginApi = _testOnly_initGerritPluginApi();
@@ -89,14 +90,12 @@
const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
element.$.ccs.$.entry.setText('test');
- MockInteractions.tap(element.shadowRoot
- .querySelector('gr-button.send'));
+ MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
assert.isFalse(sendStub.called);
flush();
element.$.ccs.$.entry.setText('test@test.test');
- MockInteractions.tap(element.shadowRoot
- .querySelector('gr-button.send'));
+ MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
assert.isTrue(sendStub.called);
});
@@ -107,9 +106,7 @@
replyApi.addReplyTextChangedCallback(text => {
const label = 'Code-Review';
const labelValue = replyApi.getLabelValue(label);
- if (labelValue &&
- labelValue === ' 0' &&
- text.indexOf('LGTM') === 0) {
+ if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
replyApi.setLabelValue(label, '+1');
}
});
@@ -121,13 +118,13 @@
await flush();
const textarea = queryAndAssert(element, 'gr-textarea').getNativeTextarea();
textarea.value = 'LGTM';
- textarea.dispatchEvent(new CustomEvent(
- 'input', {bubbles: true, composed: true}));
+ textarea.dispatchEvent(
+ new CustomEvent('input', {bubbles: true, composed: true}));
await flush();
- const labelScoreRows = element.getLabelScores().shadowRoot
- .querySelector('gr-label-score-row[name="Code-Review"]');
- const selectedBtn = labelScoreRows.shadowRoot
- .querySelector('gr-tooltip-content[data-value="+1"] > gr-button');
+ const labelScoreRows = element.getLabelScores().shadowRoot.querySelector(
+ 'gr-label-score-row[name="Code-Review"]');
+ const selectedBtn =
+ labelScoreRows.shadowRoot.querySelector('gr-button[data-value="+1"]');
assert.isOk(selectedBtn);
});
});
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 17036e6..b8932e5 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
@@ -38,7 +38,6 @@
ReviewerState,
SpecialFilePath,
} from '../../../constants/constants';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {
accountOrGroupKey,
isReviewerOrCC,
@@ -99,9 +98,9 @@
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {
- CODE_REVIEW,
getApprovalInfo,
getMaxAccounts,
+ StandardLabels,
} from '../../../utils/label-util';
import {pluralize} from '../../../utils/string-util';
import {
@@ -116,6 +115,7 @@
import {StorageLocation} from '../../../services/storage/gr-storage';
import {Interaction, Timing} from '../../../constants/reporting';
import {getReplyByReason} from '../../../utils/attention-set-util';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -163,11 +163,8 @@
};
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-reply-dialog')
-export class GrReplyDialog extends base {
+export class GrReplyDialog extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -368,12 +365,8 @@
private storeTask?: DelayedTask;
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- 'ctrl+enter meta+enter': '_handleEnterKey',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
constructor() {
super();
@@ -391,6 +384,17 @@
if (account) this._account = account;
});
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+ this._submit()
+ )
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+ this._submit()
+ )
+ );
+ this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
this.addEventListener('comment-editing-changed', e => {
this._commentEditing = (e as CustomEvent).detail;
});
@@ -418,6 +422,8 @@
override disconnectedCallback() {
this.storeTask?.cancel();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
super.disconnectedCallback();
}
@@ -492,14 +498,6 @@
return (selectorEl as GrLabelScoreRow).selectedValue;
}
- _handleEscKey() {
- this.cancel();
- }
-
- _handleEnterKey() {
- this._submit();
- }
-
@observe('_ccs.splices')
_ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
this._reviewerTypeChanged(splices, ReviewerType.CC);
@@ -986,7 +984,7 @@
}
_computeCommentAccounts(threads: CommentThread[]) {
- const crLabel = this.change?.labels?.[CODE_REVIEW];
+ const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW];
const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
const accountIds = new Set<AccountId>();
threads.forEach(thread => {
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 e57ffc7..5a11f47 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
@@ -32,7 +32,7 @@
import {addListenerForTest} from '../../../test/test-utils';
import {stubRestApi} from '../../../test/test-utils';
import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {CODE_REVIEW} from '../../../utils/label-util';
+import {StandardLabels} from '../../../utils/label-util';
import {
createAccountWithId,
createChange,
@@ -1799,7 +1799,7 @@
test('emits cancel on esc key', () => {
const cancelHandler = sinon.spy();
element.addEventListener('cancel', cancelHandler);
- pressAndReleaseKeyOn(element, 27, null, 'esc');
+ pressAndReleaseKeyOn(element, 27, null, 'Escape');
flush();
assert.isTrue(cancelHandler.called);
@@ -1808,14 +1808,14 @@
test('should not send on enter key', () => {
stubSaveReview(() => undefined);
element.addEventListener('send', () => assert.fail('wrongly called'));
- pressAndReleaseKeyOn(element, 13, null, 'enter');
+ pressAndReleaseKeyOn(element, 13, null, 'Enter');
});
test('emit send on ctrl+enter key', async () => {
stubSaveReview(() => undefined);
const promise = mockPromise();
element.addEventListener('send', () => promise.resolve());
- pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+ pressAndReleaseKeyOn(element, 13, 'ctrl', 'Enter');
await promise;
});
@@ -2116,9 +2116,9 @@
test('_computeSendButtonDisabled_existingVote', async () => {
const account = createAccountWithId();
- (element.change!.labels![CODE_REVIEW]! as DetailedLabelInfo).all = [
- account,
- ];
+ (
+ element.change!.labels![StandardLabels.CODE_REVIEW]! as DetailedLabelInfo
+ ).all = [account];
await flush();
// User has already voted.
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
new file mode 100644
index 0000000..ebe3ce3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
@@ -0,0 +1,57 @@
+/**
+ * @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 '../gr-submit-requirements/gr-submit-requirements';
+import {customElement, property} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {ParsedChangeInfo} from '../../../types/types';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
+
+@customElement('gr-submit-requirement-dashboard-hovercard')
+export class GrSubmitRequirementDashboardHovercard extends base {
+ @property({type: Object})
+ change?: ParsedChangeInfo;
+
+ static override get styles() {
+ return [
+ base.styles || [],
+ css`
+ #container {
+ padding: var(--spacing-xl);
+ padding-left: var(--spacing-s);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ return html`<div id="container" role="tooltip" tabindex="-1">
+ <gr-submit-requirements
+ .change=${this.change}
+ suppress-title
+ ></gr-submit-requirements>
+ </div>`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-submit-requirement-dashboard-hovercard': GrSubmitRequirementDashboardHovercard;
+ }
+}
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 e302b43..157336d 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
@@ -36,6 +36,7 @@
hasNeutralStatus,
hasVotes,
iconForStatus,
+ orderSubmitRequirements,
} from '../../../utils/label-util';
import {fontStyles} from '../../../styles/gr-font-styles';
import {charsOnly, pluralize} from '../../../utils/string-util';
@@ -48,6 +49,9 @@
import {Category} from '../../../api/checks';
import '../../shared/gr-vote-chip/gr-vote-chip';
+/**
+ * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
+ */
@customElement('gr-submit-requirements')
export class GrSubmitRequirements extends LitElement {
@property({type: Object})
@@ -66,6 +70,9 @@
return [
fontStyles,
css`
+ :host([suppress-title]) .metadata-title {
+ display: none;
+ }
.metadata-title {
color: var(--deemphasized-text-color);
padding-left: var(--metadata-horizontal-padding);
@@ -77,7 +84,8 @@
width: var(--line-height-normal, 20px);
height: var(--line-height-normal, 20px);
}
- iron-icon.check {
+ iron-icon.check,
+ iron-icon.overridden {
color: var(--success-foreground);
}
iron-icon.close {
@@ -106,6 +114,7 @@
}
td {
padding: var(--spacing-s);
+ white-space: nowrap;
}
.votes-cell {
display: flex;
@@ -130,9 +139,19 @@
}
override render() {
- const submit_requirements = (this.change?.submit_requirements ?? []).filter(
- req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
+ let submit_requirements = orderSubmitRequirements(
+ this.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 html` <h3
class="metadata-title heading-3"
id="submit-requirements-caption"
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 541d877..8610999 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -21,13 +21,12 @@
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
import {
- KeyboardShortcutMixin,
ShortcutSection,
- ShortcutListener,
SectionView,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {property, customElement} from '@polymer/decorators';
import {appContext} from '../../../services/app-context';
+import {ShortcutViewListener} from '../../../services/shortcuts/shortcuts-service';
declare global {
interface HTMLElementTagNameMap {
@@ -40,11 +39,8 @@
shortcuts?: SectionView;
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-keyboard-shortcuts-dialog')
-export class GrKeyboardShortcutsDialog extends base {
+export class GrKeyboardShortcutsDialog extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -61,7 +57,7 @@
@property({type: Array})
_right?: SectionShortcut[];
- private readonly shortcutListener: ShortcutListener;
+ private readonly shortcutListener: ShortcutViewListener;
private readonly shortcuts = appContext.shortcutsService;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index dd1ebd1..2901b8a 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -22,6 +22,7 @@
import {
KeyboardShortcutMixin,
Shortcut,
+ ShortcutListener,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {customElement, property} from '@polymer/decorators';
import {ServerInfo} from '../../../types/common';
@@ -31,9 +32,9 @@
GrAutocomplete,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {getDocsBaseUrl} from '../../../utils/url-util';
-import {IronKeyboardEvent} from '../../../types/events';
import {MergeabilityComputationBehavior} from '../../../constants/constants';
import {appContext} from '../../../services/app-context';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
// Possible static search options for auto complete, without negations.
const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -169,9 +170,6 @@
value = '';
@property({type: Object})
- keyEventTarget: unknown = document.body;
-
- @property({type: Object})
query: AutocompleteQuery;
@property({type: Object})
@@ -197,8 +195,6 @@
private readonly restApiService = appContext.restApiService;
- private readonly shortcuts = appContext.shortcutsService;
-
constructor() {
super();
this.query = (input: string) => this._getSearchSuggestions(input);
@@ -245,10 +241,8 @@
}
}
- override keyboardShortcuts() {
- return {
- [Shortcut.SEARCH]: '_handleSearch',
- };
+ override keyboardShortcuts(): ShortcutListener[] {
+ return [listen(Shortcut.SEARCH, _ => this._handleSearch())];
}
_valueChanged(value: string) {
@@ -396,16 +390,7 @@
});
}
- _handleSearch(e: IronKeyboardEvent) {
- const keyboardEvent = e.detail.keyboardEvent;
- if (
- this.shortcuts.shouldSuppress(e) ||
- (this.shortcuts.modifierPressed(e) && !keyboardEvent.shiftKey)
- ) {
- return;
- }
-
- e.preventDefault();
+ _handleSearch() {
this.$.searchInput.focus();
this.$.searchInput.selectAll();
}
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 b99a17b..fc22a58 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
@@ -580,7 +580,10 @@
/**
* Computes a number of unresolved comment threads in a given file and path.
*/
- computeUnresolvedNum(file: PatchSetFile | PatchNumOnly) {
+ computeUnresolvedNum(
+ file: PatchSetFile | PatchNumOnly,
+ ignorePatchsetLevelComments?: boolean
+ ) {
let comments: Comment[] = [];
let drafts: Comment[] = [];
@@ -595,7 +598,11 @@
comments = comments.concat(drafts);
const threads = createCommentThreads(comments);
- const unresolvedThreads = threads.filter(isUnresolved);
+ let unresolvedThreads = threads.filter(isUnresolved);
+ if (ignorePatchsetLevelComments)
+ unresolvedThreads = unresolvedThreads.filter(
+ thread => !isPatchsetLevel(thread)
+ );
return unresolvedThreads.length;
}
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 b1bad1c..a828b9c 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
@@ -88,10 +88,11 @@
import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
import {Timing} from '../../../constants/reporting';
import {changeComments$} from '../../../services/comments/comments-model';
-import {takeUntil} from 'rxjs/operators';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
import {Subject} from 'rxjs';
import {RenderPreferences} from '../../../api/diff';
+import {diffViewMode$} from '../../../services/browser/browser-model';
+import {takeUntil} from 'rxjs/operators';
const EMPTY_BLAME = 'No blame information for this diff.';
@@ -205,12 +206,12 @@
@property({type: Boolean})
lineWrapping = false;
- @property({type: String})
- viewMode = DiffViewMode.SIDE_BY_SIDE;
-
@property({type: Object})
lineOfInterest?: LineOfInterest;
+ @property({type: String})
+ viewMode = DiffViewMode.SIDE_BY_SIDE;
+
@property({type: Boolean})
showLoadFailure?: boolean;
@@ -312,6 +313,9 @@
override connectedCallback() {
super.connectedCallback();
+ diffViewMode$
+ .pipe(takeUntil(this.disconnected$))
+ .subscribe(diffView => (this.viewMode = diffView));
this._getLoggedIn().then(loggedIn => {
this._loggedIn = loggedIn;
});
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 b47c51c..3d43ef3 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
@@ -26,6 +26,9 @@
import {FixIronA11yAnnouncer} from '../../../types/types';
import {appContext} from '../../../services/app-context';
import {fireIronAnnounce} from '../../../utils/event-util';
+import {diffViewMode$} from '../../../services/browser/browser-model';
+import {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
@customElement('gr-diff-mode-selector')
export class GrDiffModeSelector extends PolymerElement {
@@ -34,7 +37,7 @@
}
@property({type: String, notify: true})
- mode?: DiffViewMode;
+ mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
/**
* If set to true, the user's preference will be updated every time a
@@ -48,11 +51,24 @@
private readonly userService = appContext.userService;
+ disconnected$ = new Subject();
+
+ constructor() {
+ super();
+ }
+
override connectedCallback() {
super.connectedCallback();
(
IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
).requestAvailability();
+ diffViewMode$
+ .pipe(takeUntil(this.disconnected$))
+ .subscribe(diffView => (this.mode = diffView));
+ }
+
+ override disconnectedCallback() {
+ this.disconnected$.next();
}
/**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 8b06c75..fe5f389 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -20,6 +20,7 @@
import {GrDiffModeSelector} from './gr-diff-mode-selector';
import {DiffViewMode} from '../../../constants/constants';
import {stubUsers} from '../../../test/test-utils';
+import {_testOnly_setState} from '../../../services/browser/browser-model';
const basicFixture = fixtureFromElement('gr-diff-mode-selector');
@@ -47,8 +48,10 @@
});
test('setMode', () => {
+ _testOnly_setState({screenWidth: 0});
const saveStub = stubUsers('updatePreferences');
+ flush();
// Setting the mode initially does not save prefs.
element.saveOnChange = true;
element.setMode(DiffViewMode.SIDE_BY_SIDE);
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 084f9f6..d892c79 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
@@ -37,6 +37,7 @@
import {
KeyboardShortcutMixin,
Shortcut,
+ ShortcutListener,
ShortcutSection,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {
@@ -94,26 +95,22 @@
import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
import {
CommentMap,
- isInBaseOfPatchRange,
getPatchRangeForCommentUrl,
+ isInBaseOfPatchRange,
} from '../../../utils/comment-util';
import {AppElementParams} from '../../gr-app-types';
-import {
- IronKeyboardEventListener,
- IronKeyboardEvent,
- EventType,
- OpenFixPreviewEvent,
-} from '../../../types/events';
+import {EventType, OpenFixPreviewEvent} from '../../../types/events';
import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
import {GerritView} from '../../../services/router/router-model';
import {assertIsDefined} from '../../../utils/common-util';
-import {toggleClass} from '../../../utils/dom-util';
+import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
import {CursorMoveResult} from '../../../api/core';
import {throttleWrap} from '../../../utils/async-util';
import {changeComments$} from '../../../services/comments/comments-model';
import {takeUntil} from 'rxjs/operators';
import {Subject} from 'rxjs';
import {preferences$} from '../../../services/user/user-model';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
const LOADING_BLAME = 'Loading blame...';
@@ -168,10 +165,7 @@
@property({type: Object, observer: '_paramsChanged'})
params?: AppElementParams;
- @property({type: Object})
- keyEventTarget: HTMLElement = document.body;
-
- @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+ @property({type: Object, notify: true})
changeViewState: Partial<ChangeViewState> = {};
@property({type: Object})
@@ -228,12 +222,6 @@
@property({type: Object})
_userPrefs?: PreferencesInfo;
- @property({
- type: String,
- computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
- })
- _diffMode?: string;
-
@property({type: Boolean})
_isImageDiff?: boolean;
@@ -281,63 +269,87 @@
patchNum?: PatchSetNum;
} = {};
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
- override keyboardShortcuts() {
- return {
- [Shortcut.LEFT_PANE]: '_handleLeftPane',
- [Shortcut.RIGHT_PANE]: '_handleRightPane',
- [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
- [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
- [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
- [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
- [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
- [Shortcut.NEW_COMMENT]: '_handleNewComment',
- [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
- [Shortcut.NEXT_FILE]: '_handleNextFile',
- [Shortcut.PREV_FILE]: '_handlePrevFile',
- [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
- [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
- [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
- [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
- [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
- [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
- [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
- [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
- [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
- [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
- [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
- [Shortcut.TOGGLE_ALL_DIFF_CONTEXT]: '_handleToggleAllDiffContext',
- [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
- [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
- [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
- '_handleToggleHideAllCommentThreads',
- [Shortcut.OPEN_FILE_LIST]: '_handleOpenFileList',
- [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
- [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
- [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
-
- // Final two are actually handled by gr-comment-thread.
- [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
- [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
- };
+ override keyboardShortcuts(): ShortcutListener[] {
+ return [
+ listen(Shortcut.LEFT_PANE, _ => this.cursor.moveLeft()),
+ listen(Shortcut.RIGHT_PANE, _ => this.cursor.moveRight()),
+ listen(Shortcut.NEXT_LINE, _ => this._handleNextLine()),
+ listen(Shortcut.PREV_LINE, _ => this._handlePrevLine()),
+ listen(Shortcut.VISIBLE_LINE, _ => this.cursor.moveToVisibleArea()),
+ listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
+ this._moveToNextFileWithComment()
+ ),
+ listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
+ this._moveToPreviousFileWithComment()
+ ),
+ listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
+ listen(Shortcut.SAVE_COMMENT, _ => {}),
+ listen(Shortcut.NEXT_FILE, _ => this._handleNextFile()),
+ listen(Shortcut.PREV_FILE, _ => this._handlePrevFile()),
+ listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
+ listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
+ listen(Shortcut.NEXT_COMMENT_THREAD, _ =>
+ this._handleNextCommentThread()
+ ),
+ listen(Shortcut.PREV_COMMENT_THREAD, _ =>
+ this._handlePrevCommentThread()
+ ),
+ listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
+ listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
+ listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
+ this._handleOpenDownloadDialog()
+ ),
+ listen(Shortcut.UP_TO_CHANGE, _ => this._handleUpToChange()),
+ listen(Shortcut.OPEN_DIFF_PREFS, _ => this._handleCommaKey()),
+ listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
+ listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
+ if (this._throttledToggleFileReviewed) {
+ this._throttledToggleFileReviewed(e);
+ }
+ }),
+ listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ =>
+ this._handleToggleAllDiffContext()
+ ),
+ listen(Shortcut.NEXT_UNREVIEWED_FILE, _ =>
+ this._handleNextUnreviewedFile()
+ ),
+ listen(Shortcut.TOGGLE_BLAME, _ => this._handleToggleBlame()),
+ listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+ this._handleToggleHideAllCommentThreads()
+ ),
+ listen(Shortcut.OPEN_FILE_LIST, _ => this._handleOpenFileList()),
+ listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
+ listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
+ this._handleDiffAgainstLatest()
+ ),
+ listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+ this._handleDiffBaseAgainstLeft()
+ ),
+ listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+ this._handleDiffRightAgainstLatest()
+ ),
+ listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+ this._handleDiffBaseAgainstLatest()
+ ),
+ listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
+ listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+ ];
}
private readonly reporting = appContext.reportingService;
private readonly restApiService = appContext.restApiService;
+ private readonly userService = appContext.userService;
+
private readonly commentsService = appContext.commentsService;
private readonly shortcuts = appContext.shortcutsService;
- _throttledToggleFileReviewed?: IronKeyboardEventListener;
+ _throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
_onRenderHandler?: EventListener;
@@ -347,8 +359,8 @@
override connectedCallback() {
super.connectedCallback();
- this._throttledToggleFileReviewed = throttleWrap(e =>
- this._handleToggleFileReviewed(e)
+ this._throttledToggleFileReviewed = throttleWrap(_ =>
+ this._handleToggleFileReviewed()
);
this._getLoggedIn().then(loggedIn => {
this._loggedIn = loggedIn;
@@ -373,6 +385,12 @@
this.cursor.reInitCursor();
};
this.$.diffHost.addEventListener('render', this._onRenderHandler);
+ this.cleanups.push(
+ addGlobalShortcut(
+ {key: Key.ESC},
+ _ => (this.$.diffHost.displayLine = false)
+ )
+ );
}
override disconnectedCallback() {
@@ -381,6 +399,8 @@
if (this._onRenderHandler) {
this.$.diffHost.removeEventListener('render', this._onRenderHandler);
}
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
super.disconnectedCallback();
}
@@ -523,83 +543,20 @@
);
}
- _handleToggleFileReviewed(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
-
- e.preventDefault();
+ _handleToggleFileReviewed() {
this._setReviewed(!this.$.reviewed.checked);
}
- _handleEscKey(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
-
- e.preventDefault();
- this.$.diffHost.displayLine = false;
- }
-
- _handleLeftPane(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
-
- e.preventDefault();
- this.cursor.moveLeft();
- }
-
- _handleRightPane(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
-
- e.preventDefault();
- this.cursor.moveRight();
- }
-
- _handlePrevLineOrFileWithComments(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
-
- if (
- e.detail.keyboardEvent?.shiftKey &&
- e.detail.keyboardEvent?.keyCode === 75
- ) {
- // 'K'
- this._moveToPreviousFileWithComment();
- return;
- }
- if (this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _handlePrevLine() {
this.$.diffHost.displayLine = true;
this.cursor.moveUp();
}
- _handleVisibleLine(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
-
- e.preventDefault();
- this.cursor.moveToVisibleArea();
- }
-
_onOpenFixPreview(e: OpenFixPreviewEvent) {
this.$.applyFixDialog.open(e);
}
- _handleNextLineOrFileWithComments(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
-
- if (
- e.detail.keyboardEvent?.shiftKey &&
- e.detail.keyboardEvent?.keyCode === 74
- ) {
- // 'J'
- this._moveToNextFileWithComment();
- return;
- }
- if (this.shortcuts.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
+ _handleNextLine() {
this.$.diffHost.displayLine = true;
this.cursor.moveDown();
}
@@ -643,59 +600,34 @@
);
}
- _handleNewComment(ike: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(ike)) return;
- if (this.shortcuts.modifierPressed(ike)) return;
-
- ike.preventDefault();
+ _handleNewComment() {
this.classList.remove('hideComments');
this.cursor.createCommentInPlace();
}
- _handlePrevFile(ike: IronKeyboardEvent) {
- const ke = ike.detail.keyboardEvent;
- if (this.shortcuts.shouldSuppress(ike)) return;
- // Check for meta key to avoid overriding native chrome shortcut.
- if (ke.metaKey) return;
+ _handlePrevFile() {
if (!this._path) return;
if (!this._fileList) return;
-
- ike.preventDefault();
this._navToFile(this._path, this._fileList, -1);
}
- _handleNextFile(ike: IronKeyboardEvent) {
- const ke = ike.detail.keyboardEvent;
- if (this.shortcuts.shouldSuppress(ike)) return;
- // Check for meta key to avoid overriding native chrome shortcut.
- if (ke.metaKey) return;
+ _handleNextFile() {
if (!this._path) return;
if (!this._fileList) return;
-
- ike.preventDefault();
this._navToFile(this._path, this._fileList, 1);
}
- _handleNextChunkOrCommentThread(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _handleNextChunk() {
+ const result = this.cursor.moveToNextChunk();
+ if (result === CursorMoveResult.CLIPPED && this.cursor.isAtEnd()) {
+ this.showToastAndNavigateFile('next', 'n');
+ }
+ }
- e.preventDefault();
- if (e.detail.keyboardEvent?.shiftKey) {
- const result = this.cursor.moveToNextCommentThread();
- if (result === CursorMoveResult.CLIPPED) {
- this._navigateToNextFileWithCommentThread();
- }
- } else {
- if (this.shortcuts.modifierPressed(e)) return;
- const result = this.cursor.moveToNextChunk();
- // navigate to next file if key is not being held down
- if (
- !e.detail.keyboardEvent?.repeat &&
- result === CursorMoveResult.CLIPPED &&
- this.cursor.isAtEnd()
- ) {
- this.showToastAndNavigateFile('next', 'n');
- }
+ _handleNextCommentThread() {
+ const result = this.cursor.moveToNextCommentThread();
+ if (result === CursorMoveResult.CLIPPED) {
+ this._navigateToNextFileWithCommentThread();
}
}
@@ -737,25 +669,19 @@
this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
}
- _handlePrevChunkOrCommentThread(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
-
- e.preventDefault();
- if (e.detail.keyboardEvent?.shiftKey) {
- this.cursor.moveToPreviousCommentThread();
- } else {
- if (this.shortcuts.modifierPressed(e)) return;
- this.cursor.moveToPreviousChunk();
- if (!e.detail.keyboardEvent?.repeat && this.cursor.isAtStart()) {
- this.showToastAndNavigateFile('previous', 'p');
- }
+ _handlePrevChunk() {
+ this.cursor.moveToPreviousChunk();
+ if (this.cursor.isAtStart()) {
+ this.showToastAndNavigateFile('previous', 'p');
}
}
+ _handlePrevCommentThread() {
+ this.cursor.moveToPreviousCommentThread();
+ }
+
// Similar to gr-change-view._handleOpenReplyDialog
- _handleOpenReplyDialog(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
+ _handleOpenReplyDialog() {
this._getLoggedIn().then(isLoggedIn => {
if (!isLoggedIn) {
fireEvent(this, 'show-auth-required');
@@ -763,54 +689,36 @@
}
this.set('changeViewState.showReplyDialog', true);
- e.preventDefault();
this._navToChangeView();
});
}
- _handleToggleLeftPane(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (!e.detail.keyboardEvent?.shiftKey) return;
-
- e.preventDefault();
+ _handleToggleLeftPane() {
this.$.diffHost.toggleLeftDiff();
}
- _handleOpenDownloadDialog(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
-
+ _handleOpenDownloadDialog() {
this.set('changeViewState.showDownloadDialog', true);
- e.preventDefault();
this._navToChangeView();
}
- _handleUpToChange(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
-
- e.preventDefault();
+ _handleUpToChange() {
this._navToChangeView();
}
- _handleCommaKey(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
+ _handleCommaKey() {
if (!this._loggedIn) return;
-
- e.preventDefault();
this.$.diffPreferencesDialog.open();
}
- _handleToggleDiffMode(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
-
- e.preventDefault();
- if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
- this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+ _handleToggleDiffMode() {
+ if (!this._userPrefs) return;
+ if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
+ this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
} else {
- this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+ this.userService.updatePreferences({
+ diff_view: DiffViewMode.SIDE_BY_SIDE,
+ });
}
}
@@ -1217,17 +1125,6 @@
});
}
- _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
- if (changeViewState.diffMode === null) {
- // If screen size is small, always default to unified view.
- this.restApiService.getPreferences().then(prefs => {
- if (prefs) {
- this.set('changeViewState.diffMode', prefs.default_diff_view);
- }
- });
- }
- }
-
@observe('_path', '_prefs', '_reviewedFiles', '_patchRange')
_setReviewedObserver(
path?: string,
@@ -1442,29 +1339,6 @@
this.$.diffPreferencesDialog.open();
}
- /**
- * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
- * the current state.
- *
- * The expected behavior is to use the mode specified in the user's
- * preferences unless they have manually chosen the alternative view or they
- * are on a mobile device. If the user navigates up to the change view, it
- * should clear this choice and revert to the preference the next time a
- * diff is viewed.
- *
- * Use side-by-side if the user is not logged in.
- */
- _getDiffViewMode() {
- if (this.changeViewState.diffMode) {
- return this.changeViewState.diffMode;
- } else if (this._userPrefs) {
- this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
- return this._userPrefs.default_diff_view;
- } else {
- return 'SIDE_BY_SIDE';
- }
- }
-
_computeModeSelectHideClass(diff?: DiffInfo) {
return !diff || diff.binary ? 'hide' : '';
}
@@ -1689,28 +1563,19 @@
this._loadBlame();
}
- _handleToggleBlame(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
-
+ _handleToggleBlame() {
this._toggleBlame();
}
- _handleToggleHideAllCommentThreads(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
-
+ _handleToggleHideAllCommentThreads() {
toggleClass(this, 'hideComments');
}
- _handleOpenFileList(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- if (this.shortcuts.modifierPressed(e)) return;
+ _handleOpenFileList() {
this.$.dropdown.open();
}
- _handleDiffAgainstBase(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _handleDiffAgainstBase() {
if (!this._change) return;
if (!this._path) return;
if (!this._patchRange) return;
@@ -1726,8 +1591,7 @@
);
}
- _handleDiffBaseAgainstLeft(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _handleDiffBaseAgainstLeft() {
if (!this._change) return;
if (!this._path) return;
if (!this._patchRange) return;
@@ -1747,8 +1611,7 @@
);
}
- _handleDiffAgainstLatest(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _handleDiffAgainstLatest() {
if (!this._change) return;
if (!this._path) return;
if (!this._patchRange) return;
@@ -1767,8 +1630,7 @@
);
}
- _handleDiffRightAgainstLatest(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _handleDiffRightAgainstLatest() {
if (!this._change) return;
if (!this._path) return;
if (!this._patchRange) return;
@@ -1786,8 +1648,7 @@
);
}
- _handleDiffBaseAgainstLatest(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _handleDiffBaseAgainstLatest() {
if (!this._change) return;
if (!this._path) return;
if (!this._patchRange) return;
@@ -1824,14 +1685,11 @@
return '';
}
- _handleToggleAllDiffContext(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
-
+ _handleToggleAllDiffContext() {
this.$.diffHost.toggleAllContext();
}
- _handleNextUnreviewedFile(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _handleNextUnreviewedFile() {
this._setReviewed(true);
this.navigateToUnreviewedFile('next');
}
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 308c353..16adb45 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
@@ -339,7 +339,6 @@
<gr-diff-mode-selector
id="modeSelect"
save-on-change="[[_loggedIn]]"
- mode="{{changeViewState.diffMode}}"
show-tooltip-below=""
></gr-diff-mode-selector>
</div>
@@ -409,7 +408,6 @@
path="[[_path]]"
prefs="[[_prefs]]"
project-name="[[_change.project]]"
- view-mode="[[_diffMode]]"
is-blame-loaded="{{_isBlameLoaded}}"
on-comment-anchor-tap="_onLineSelected"
on-line-selected="_onLineSelected"
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 cc35c3c..cc9aac0 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
@@ -18,8 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-diff-view.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeStatus, DiffViewMode} from '../../../constants/constants.js';
+import {stubRestApi, stubUsers} from '../../../test/test-utils.js';
import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
import {GerritView} from '../../../services/router/router-model.js';
import {
@@ -30,11 +30,10 @@
import {EditPatchSetNum} from '../../../types/common.js';
import {CursorMoveResult} from '../../../api/core.js';
import {EventType} from '../../../types/events.js';
+import {_testOnly_resetState, _testOnly_setState} from '../../../services/browser/browser-model.js';
const basicFixture = fixtureFromElement('gr-diff-view');
-const blankFixture = fixtureFromElement('div');
-
suite('gr-diff-view tests', () => {
suite('basic tests', () => {
let element;
@@ -70,6 +69,8 @@
stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
stubRestApi('getPortedComments').returns(Promise.resolve({}));
+ _testOnly_resetState();
+
element = basicFixture.instantiate();
element._changeNum = '42';
element._path = 'some/path.txt';
@@ -426,12 +427,13 @@
test('toggle left diff with a hotkey', () => {
const toggleLeftDiffStub = sinon.stub(
element.$.diffHost, 'toggleLeftDiff');
- MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+ MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
assert.isTrue(toggleLeftDiffStub.calledOnce);
});
test('keyboard shortcuts', () => {
element._changeNum = '42';
+ _testOnly_setState({screenWidth: 0});
element._patchRange = {
basePatchNum: PARENT,
patchNum: 10,
@@ -498,40 +500,47 @@
assert(scrollStub.calledOnce);
scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
- MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+ MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
assert(scrollStub.calledOnce);
scrollStub = sinon.stub(element.cursor,
'moveToPreviousCommentThread');
- MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
+ MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'P');
assert(scrollStub.calledOnce);
const computeContainerClassStub = sinon.stub(element.$.diffHost.$.diff,
'_computeContainerClass');
MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
assert(computeContainerClassStub.lastCall.calledWithExactly(
- false, 'SIDE_BY_SIDE', true));
+ false, DiffViewMode.SIDE_BY_SIDE, true));
- MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+ MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
assert(computeContainerClassStub.lastCall.calledWithExactly(
- false, 'SIDE_BY_SIDE', false));
+ false, DiffViewMode.SIDE_BY_SIDE, false));
+ // Note that stubbing _setReviewed means that the value of the
+ // `element.$.reviewed` checkbox is not flipped.
sinon.stub(element, '_setReviewed');
sinon.spy(element, '_handleToggleFileReviewed');
element.$.reviewed.checked = false;
- MockInteractions.keyUpOn(element, 82, 'shift', 'r');
+ assert.isFalse(element._handleToggleFileReviewed.called);
assert.isFalse(element._setReviewed.called);
- assert.isTrue(element._handleToggleFileReviewed.calledOnce);
- MockInteractions.keyUpOn(element, 82, null, 'r');
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+ assert.isTrue(element._setReviewed.calledOnce);
+ assert.equal(element._setReviewed.lastCall.args[0], true);
+
+ // Handler is throttled, so another key press within 500 ms is ignored.
+ clock.tick(100);
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+ assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+ assert.isTrue(element._setReviewed.calledOnce);
clock.tick(1000);
-
- MockInteractions.keyUpOn(element, 82, null, 'r');
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
assert.isTrue(element._handleToggleFileReviewed.calledTwice);
- assert.isTrue(element._setReviewed.called);
- assert.equal(element._setReviewed.lastCall.args[0], true);
+ assert.isTrue(element._setReviewed.calledTwice);
});
test('moveToNextCommentThread navigates to next file', () => {
@@ -563,14 +572,14 @@
element.changeViewState.selectedFileIndex = 1;
element._loggedIn = true;
- MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+ MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
flush();
assert.isTrue(diffNavStub.calledWithExactly(
element._change, 'wheatley.md', 10, PARENT, 21));
element._path = 'wheatley.md'; // navigated to next file
- MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+ MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
flush();
assert.isTrue(diffChangeStub.called);
@@ -578,7 +587,7 @@
test('shift+x shortcut toggles all diff context', () => {
const toggleStub = sinon.stub(element.$.diffHost, 'toggleAllContext');
- MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+ MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'X');
flush();
assert.isTrue(toggleStub.called);
});
@@ -691,7 +700,7 @@
sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
const loggedInErrorSpy = sinon.spy();
element.addEventListener('show-auth-required', loggedInErrorSpy);
- MockInteractions.keyUpOn(element, 65, null, 'a');
+ MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
await flush();
assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
'should only work when the user is logged in.');
@@ -717,7 +726,7 @@
sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
const loggedInErrorSpy = sinon.spy();
element.addEventListener('show-auth-required', loggedInErrorSpy);
- MockInteractions.keyUpOn(element, 65, null, 'a');
+ MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
await flush();
assert.isTrue(element.changeViewState.showReplyDialog);
assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
@@ -743,7 +752,7 @@
sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
const loggedInErrorSpy = sinon.spy();
element.addEventListener('show-auth-required', loggedInErrorSpy);
- MockInteractions.keyUpOn(element, 65, null, 'a');
+ MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
await flush();
assert.isTrue(element.changeViewState.showReplyDialog);
assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
@@ -807,7 +816,7 @@
'Should navigate to /c/42/5..10');
assert.isUndefined(element.changeViewState.showDownloadDialog);
- MockInteractions.keyUpOn(element, 68, null, 'd');
+ MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
assert.isTrue(element.changeViewState.showDownloadDialog);
});
@@ -1301,47 +1310,23 @@
test('diff mode selector correctly toggles the diff', () => {
const select = element.$.modeSelect;
const diffDisplay = element.$.diffHost;
- element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+ element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
+ _testOnly_setState({screenWidth: 0});
+ const userStub = stubUsers('updatePreferences');
+
+ flush();
// The mode selected in the view state reflects the selected option.
- assert.equal(element._getDiffViewMode(), select.mode);
+ // assert.equal(element._userPrefs.diff_view, select.mode);
// The mode selected in the view state reflects the view rednered in the
// diff.
assert.equal(select.mode, diffDisplay.viewMode);
// We will simulate a user change of the selected mode.
- const newMode = 'UNIFIED_DIFF';
-
- // Set the mode, and simulate the change event.
- element.set('changeViewState.diffMode', newMode);
-
- // Make sure the handler was called and the state is still coherent.
- assert.equal(element._getDiffViewMode(), newMode);
- assert.equal(element._getDiffViewMode(), select.mode);
- assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
- });
-
- test('diff mode selector initializes from preferences', () => {
- let resolvePrefs;
- const prefsPromise = new Promise(resolve => {
- resolvePrefs = resolve;
- });
- stubRestApi('getPreferences')
- .callsFake(() => prefsPromise);
-
- // Attach a new gr-diff-view so we can intercept the preferences fetch.
- const view = document.createElement('gr-diff-view');
- blankFixture.instantiate().appendChild(view);
- flush();
-
- // At this point the diff mode doesn't yet have the user's preference.
- assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
-
- // Receive the overriding preference.
- resolvePrefs({default_diff_view: 'UNIFIED'});
- flush();
- assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+ element._handleToggleDiffMode();
+ assert.isTrue(userStub.calledWithExactly({
+ diff_view: DiffViewMode.UNIFIED}));
});
test('diff mode selector should be hidden for binary', async () => {
@@ -1502,32 +1487,22 @@
assert.isTrue(getUrlStub.lastCall.args[6]);
});
- test('_getDiffViewMode', () => {
- // No user prefs or change view state set.
- assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
- // User prefs but no change view state set.
- element.changeViewState.diffMode = undefined;
- element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
- assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
- // User prefs and change view state set.
- element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
- assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
- });
-
test('_handleToggleDiffMode', () => {
+ const userStub = stubUsers('updatePreferences');
const e = new CustomEvent('keydown', {
detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
});
- // Initial state.
- assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+ element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
element._handleToggleDiffMode(e);
- assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+ assert.deepEqual(userStub.lastCall.args[0], {
+ diff_view: DiffViewMode.UNIFIED});
+
+ element._userPrefs = {diff_view: DiffViewMode.UNIFIED};
element._handleToggleDiffMode(e);
- assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+ assert.deepEqual(userStub.lastCall.args[0], {
+ diff_view: DiffViewMode.SIDE_BY_SIDE});
});
suite('_initPatchRange', () => {
@@ -1730,7 +1705,7 @@
test('toggle blame with shortcut', () => {
const toggleBlame = sinon.stub(
element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
- MockInteractions.keyUpOn(element, 66, null, 'b');
+ MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
assert.isTrue(toggleBlame.calledOnce);
});
});
@@ -1890,7 +1865,7 @@
element._path = 'file1';
const reviewedStub = sinon.stub(element, '_setReviewed');
const navStub = sinon.stub(element, '_navToFile');
- MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+ MockInteractions.pressAndReleaseKeyOn(element, 77, null, 'M');
flush();
assert.isTrue(reviewedStub.lastCall.args[0]);
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 0d6cadc..857ffa2 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
@@ -381,7 +381,10 @@
);
const commentThreadString = pluralize(commentThreadCount, 'comment');
- const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
+ const unresolvedCount = changeComments.computeUnresolvedNum(
+ {patchNum},
+ true
+ );
const unresolvedString =
unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 0fe1fe2..28ebbac 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -351,15 +351,15 @@
],
abc: [],
// Patchset level comment does not contribute to the count
- [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: {
+ [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
id: '27dcee4d_f7b77cfa',
message: 'test',
patch_set: 1,
unresolved: true,
updated: '2017-10-11 20:48:40.000000000',
- },
+ }],
};
- element.changeComments = new ChangeComments(comments, {}, {}, 123);
+ element.changeComments = new ChangeComments(comments);
assert.equal(element._computePatchSetCommentsString(
element.changeComments, 1), ' (3 comments, 1 unresolved)');
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index 8821725..551889f 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -47,9 +47,6 @@
* @event create-comment-requested
*/
- @property({type: Object})
- keyEventTarget = document.body;
-
@property({type: Boolean})
positionBelow = false;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 3adb0f3..173a27e 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -14,28 +14,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
import '../../shared/gr-list-view/gr-list-view';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-documentation-search_html';
import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
import {DocResult} from '../../../types/common';
import {fireTitleChange} from '../../../utils/event-util';
import {appContext} from '../../../services/app-context';
import {ListViewParams} from '../../gr-app-types';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, PropertyValues, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
@customElement('gr-documentation-search')
-export class GrDocumentationSearch extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
-
+export class GrDocumentationSearch extends LitElement {
/**
* URL params passed from the router.
*/
- @property({type: Object, observer: '_paramsChanged'})
+ @property({type: Object})
params?: ListViewParams;
@property({type: Array})
@@ -54,7 +49,57 @@
fireTitleChange(this, 'Documentation Search');
}
- _paramsChanged(params: ListViewParams) {
+ static override get styles() {
+ return [sharedStyles, tableStyles];
+ }
+
+ override render() {
+ return html` <gr-list-view
+ .filter="${this._filter}"
+ .offset="${0}"
+ .loading="${this._loading}"
+ .path="/Documentation"
+ >
+ <table id="list" class="genericList">
+ <tbody>
+ <tr class="headerRow">
+ <th class="name topHeader">Name</th>
+ <th class="name topHeader"></th>
+ <th class="name topHeader"></th>
+ </tr>
+ <tr
+ id="loading"
+ class="loadingMsg ${this.computeLoadingClass(this._loading)}"
+ >
+ <td>Loading...</td>
+ </tr>
+ </tbody>
+ <tbody class="${this.computeLoadingClass(this._loading)}">
+ ${this._documentationSearches?.map(
+ search => html`
+ <tr class="table">
+ <td class="name">
+ <a href="${this._computeSearchUrl(search.url)}"
+ >${search.title}</a
+ >
+ </td>
+ <td></td>
+ <td></td>
+ </tr>
+ `
+ )}
+ </tbody>
+ </table>
+ </gr-list-view>`;
+ }
+
+ override updated(changedProperties: PropertyValues) {
+ if (changedProperties.has('params')) {
+ this._paramsChanged(this.params);
+ }
+ }
+
+ _paramsChanged(params?: ListViewParams) {
this._loading = true;
this._filter = params?.filter ?? '';
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
deleted file mode 100644
index 95ce1ec..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ /dev/null
@@ -1,56 +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">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <style include="gr-table-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <gr-list-view
- filter="[[_filter]]"
- offset="0"
- loading="[[_loading]]"
- path="/Documentation"
- >
- <table id="list" class="genericList">
- <tbody>
- <tr class="headerRow">
- <th class="name topHeader">Name</th>
- <th class="name topHeader"></th>
- <th class="name topHeader"></th>
- </tr>
- <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
- <td>Loading...</td>
- </tr>
- </tbody>
- <tbody class$="[[computeLoadingClass(_loading)]]">
- <template is="dom-repeat" items="[[_documentationSearches]]">
- <tr class="table">
- <td class="name">
- <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
- </td>
- <td></td>
- <td></td>
- </tr>
- </template>
- </tbody>
- </table>
- </gr-list-view>
-`;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index bf6a0d5..47c83da 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -20,7 +20,7 @@
import {GrDocumentationSearch} from './gr-documentation-search';
import {page} from '../../../utils/page-wrapper-utils';
import 'lodash/lodash';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
import {DocResult} from '../../../types/common';
import {ListViewParams} from '../../gr-app-types';
@@ -40,10 +40,11 @@
let value: ListViewParams;
- setup(() => {
+ setup(async () => {
sinon.stub(page, 'show');
element = basicFixture.instantiate();
counter = 0;
+ await flush();
});
suite('list with searches for documentation', () => {
@@ -87,13 +88,19 @@
test('correct contents are displayed', async () => {
assert.isTrue(element._loading);
assert.equal(element.computeLoadingClass(element._loading), 'loading');
- assert.equal(getComputedStyle(element.$.loading).display, 'block');
+ assert.equal(
+ getComputedStyle(queryAndAssert(element, '#loading')).display,
+ 'block'
+ );
element._loading = false;
await flush();
assert.equal(element.computeLoadingClass(element._loading), '');
- assert.equal(getComputedStyle(element.$.loading).display, 'none');
+ assert.equal(
+ getComputedStyle(queryAndAssert(element, '#loading')).display,
+ 'none'
+ );
});
});
});
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 ad7e015..24ebd67 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
@@ -22,7 +22,6 @@
import '../../../styles/shared-styles';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-editor-view_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {
GerritNav,
GenerateUrlEditViewParameters,
@@ -47,7 +46,7 @@
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {IronKeyboardEvent} from '../../../types/events';
+import {addShortcut, Modifier} from '../../../utils/dom-util';
const RESTORED_MESSAGE = 'Content restored from a previous edit.';
const SAVING_MESSAGE = 'Saving changes...';
@@ -69,11 +68,8 @@
};
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-editor-view')
-export class GrEditorView extends base {
+export class GrEditorView extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -141,11 +137,8 @@
// Tests use this so needs to be non private
storeTask?: DelayedTask;
- get keyBindings() {
- return {
- 'ctrl+s meta+s': '_handleSaveShortcut',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
constructor() {
super();
@@ -159,10 +152,22 @@
this._getEditPrefs().then(prefs => {
this._prefs = prefs;
});
+ this.cleanups.push(
+ addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, e =>
+ this._handleSaveShortcut(e)
+ )
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, e =>
+ this._handleSaveShortcut(e)
+ )
+ );
}
override disconnectedCallback() {
this.storeTask?.cancel();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
super.disconnectedCallback();
}
@@ -394,7 +399,7 @@
);
}
- _handleSaveShortcut(e: IronKeyboardEvent) {
+ _handleSaveShortcut(e: KeyboardEvent) {
e.preventDefault();
if (!this._saveDisabled) {
this._saveEdit();
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 38f55bd..7f7749a 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -43,6 +43,7 @@
import {
KeyboardShortcutMixin,
Shortcut,
+ ShortcutListener,
} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {GerritNav} from './core/gr-navigation/gr-navigation';
import {appContext} from '../services/app-context';
@@ -68,7 +69,6 @@
import {GrMainHeader} from './core/gr-main-header/gr-main-header';
import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
import {
- IronKeyboardEvent,
DialogChangeEventDetail,
EventType,
LocationChangeEvent,
@@ -81,6 +81,7 @@
import {LifeCycle} from '../constants/reporting';
import {fireIronAnnounce} from '../utils/event-util';
import {assertIsDefined} from '../utils/common-util';
+import {listen} from '../services/shortcuts/shortcuts-service';
interface ErrorInfo {
text: string;
@@ -120,9 +121,6 @@
@property({type: Object})
params?: AppElementParams;
- @property({type: Object})
- keyEventTarget = document.body;
-
@property({type: Object, observer: '_accountChanged'})
_account?: AccountDetailInfo;
@@ -214,17 +212,21 @@
private readonly restApiService = appContext.restApiService;
- private readonly shortcuts = appContext.shortcutsService;
+ private readonly browserService = appContext.browserService;
- override keyboardShortcuts() {
- return {
- [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
- [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
- [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
- [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
- [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
- [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
- };
+ override keyboardShortcuts(): ShortcutListener[] {
+ return [
+ listen(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, _ =>
+ this._showKeyboardShortcuts()
+ ),
+ listen(Shortcut.GO_TO_USER_DASHBOARD, _ => this._goToUserDashboard()),
+ listen(Shortcut.GO_TO_OPENED_CHANGES, _ => this._goToOpenedChanges()),
+ listen(Shortcut.GO_TO_MERGED_CHANGES, _ => this._goToMergedChanges()),
+ listen(Shortcut.GO_TO_ABANDONED_CHANGES, _ =>
+ this._goToAbandonedChanges()
+ ),
+ listen(Shortcut.GO_TO_WATCHED_CHANGES, _ => this._goToWatchedChanges()),
+ ];
}
constructor() {
@@ -252,6 +254,8 @@
this.handleRecreateView(GerritView.DIFF)
);
document.addEventListener(EventType.GR_RPC_LOG, e => this._handleRpcLog(e));
+ const resizeObserver = this.browserService.observeWidth();
+ resizeObserver.observe(this);
}
override ready() {
@@ -502,8 +506,7 @@
(this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
}
- _showKeyboardShortcuts(e: IronKeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
+ _showKeyboardShortcuts() {
// same shortcut should close the dialog if pressed again
// when dialog is open
this.loadKeyboardShortcutsDialog = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index df5f441..acb8348 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -62,9 +62,6 @@
@property({type: String})
placeholder = '';
- @property({type: Number})
- suggestFrom = 0;
-
@property({type: Object, notify: true})
querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
index c6c2b7f..d84ef62 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
@@ -28,7 +28,6 @@
id="input"
borderless="[[borderless]]"
placeholder="[[placeholder]]"
- threshold="[[suggestFrom]]"
query="[[querySuggestions]]"
allow-non-suggested-values="[[allowAnyInput]]"
on-commit="_handleInputCommit"
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index d97e38e..5449981 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -178,12 +178,6 @@
@property({type: Object})
_querySuggestions: (input: string) => Promise<SuggestionItem[]>;
- /**
- * Set to true to disable suggestions on empty input.
- */
- @property({type: Boolean})
- skipSuggestOnEmpty = false;
-
reporting: ReportingService;
private pendingRemoval: Set<AccountInput> = new Set();
@@ -206,17 +200,10 @@
}
_getSuggestions(input: string) {
- if (this.skipSuggestOnEmpty && !input) {
- return Promise.resolve([]);
- }
const provider = this.suggestionsProvider;
- if (!provider) {
- return Promise.resolve([]);
- }
+ if (!provider) return Promise.resolve([]);
return provider.getSuggestions(input).then(suggestions => {
- if (!suggestions) {
- return [];
- }
+ if (!suggestions) return [];
if (this.filter) {
suggestions = suggestions.filter(this.filter);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index b667aba..23e5a72 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -398,53 +398,6 @@
assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
});
- test('suggestion on empty', async () => {
- element.skipSuggestOnEmpty = false;
- const suggestions: Suggestion[] = [
- {
- email: 'abc@example.com' as EmailAddress,
- text: 'abcd',
- } as AccountInfo,
- {
- email: 'qwe@example.com' as EmailAddress,
- text: 'qwer',
- } as AccountInfo,
- ];
- const getSuggestionsStub = sinon
- .stub(suggestionsProvider, 'getSuggestions')
- .returns(Promise.resolve(suggestions));
-
- const makeSuggestionItemSpy = sinon.spy(
- suggestionsProvider,
- 'makeSuggestionItem'
- );
-
- const input = element.$.entry.$.input;
-
- input.text = '';
- MockInteractions.focus(input.$.input);
- input.noDebounce = true;
- await flush();
- assert.isTrue(getSuggestionsStub.calledOnce);
- assert.equal(getSuggestionsStub.lastCall.args[0], '');
- assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
- });
-
- test('skip suggestion on empty', async () => {
- element.skipSuggestOnEmpty = true;
- const getSuggestionsStub = sinon
- .stub(suggestionsProvider, 'getSuggestions')
- .returns(Promise.resolve([]));
-
- const input = element.$.entry.$.input;
-
- input.text = '';
- MockInteractions.focus(input.$.input);
- input.noDebounce = true;
- await flush();
- assert.isTrue(getSuggestionsStub.notCalled);
- });
-
suite('allowAnyInput', () => {
setup(() => {
element.allowAnyInput = true;
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 a629d0e..e7137e4 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
@@ -20,12 +20,12 @@
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-autocomplete-dropdown_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
import {customElement, property, observe} from '@polymer/decorators';
import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
import {fireEvent} from '../../../utils/event-util';
+import {addShortcut, Key} from '../../../utils/dom-util';
export interface GrAutocompleteDropdown {
$: {
@@ -53,10 +53,7 @@
}
// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = IronFitMixin(
- KeyboardShortcutMixin(PolymerElement),
- IronFitBehavior as IronFitBehavior
-);
+const base = IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior);
@customElement('gr-autocomplete-dropdown')
export class GrAutocompleteDropdown extends base {
@@ -91,15 +88,8 @@
@property({type: Array})
suggestions: Item[] = [];
- get keyBindings() {
- return {
- up: '_handleUp',
- down: '_handleDown',
- enter: '_handleEnter',
- esc: '_handleEscape',
- tab: '_handleTab',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
// visible for testing
cursor = new GrCursorManager();
@@ -110,8 +100,29 @@
this.cursor.focusOnMove = true;
}
+ override connectedCallback() {
+ super.connectedCallback();
+ this.cleanups.push(
+ addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ESC}, _ => this._handleEscape())
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+ );
+ }
+
override disconnectedCallback() {
this.cursor.unsetCursor();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
super.disconnectedCallback();
}
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index bb47dbc0..86de3b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -50,7 +50,7 @@
test('escape key', () => {
const closeSpy = sinon.spy(element, 'close');
- MockInteractions.pressAndReleaseKeyOn(element, 27);
+ MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
flush();
assert.isTrue(closeSpy.called);
});
@@ -59,7 +59,7 @@
const handleTabSpy = sinon.spy(element, '_handleTab');
const itemSelectedStub = sinon.stub();
element.addEventListener('item-selected', itemSelectedStub);
- MockInteractions.pressAndReleaseKeyOn(element, 9);
+ MockInteractions.pressAndReleaseKeyOn(element, 9, null, 'Tab');
assert.isTrue(handleTabSpy.called);
assert.equal(element.cursor.index, 0);
assert.isTrue(itemSelectedStub.called);
@@ -73,7 +73,7 @@
const handleEnterSpy = sinon.spy(element, '_handleEnter');
const itemSelectedStub = sinon.stub();
element.addEventListener('item-selected', itemSelectedStub);
- MockInteractions.pressAndReleaseKeyOn(element, 13);
+ MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
assert.isTrue(handleEnterSpy.called);
assert.equal(element.cursor.index, 0);
assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
@@ -85,11 +85,11 @@
test('down key', () => {
element.isHidden = true;
const nextSpy = sinon.spy(element.cursor, 'next');
- MockInteractions.pressAndReleaseKeyOn(element, 40);
+ MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
assert.isFalse(nextSpy.called);
assert.equal(element.cursor.index, 0);
element.isHidden = false;
- MockInteractions.pressAndReleaseKeyOn(element, 40);
+ MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
assert.isTrue(nextSpy.called);
assert.equal(element.cursor.index, 1);
});
@@ -97,13 +97,13 @@
test('up key', () => {
element.isHidden = true;
const prevSpy = sinon.spy(element.cursor, 'previous');
- MockInteractions.pressAndReleaseKeyOn(element, 38);
+ MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
assert.isFalse(prevSpy.called);
assert.equal(element.cursor.index, 0);
element.isHidden = false;
element.cursor.setCursorAtIndex(1);
assert.equal(element.cursor.index, 1);
- MockInteractions.pressAndReleaseKeyOn(element, 38);
+ MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
assert.isTrue(prevSpy.called);
assert.equal(element.cursor.index, 0);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 524b197..8e84aa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -22,7 +22,6 @@
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-autocomplete_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {property, customElement, observe} from '@polymer/decorators';
import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {PaperInputElementExt} from '../../../types/types';
@@ -65,11 +64,8 @@
export type AutocompleteCommitEvent =
CustomEvent<AutocompleteCommitEventDetail>;
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-autocomplete')
-export class GrAutocomplete extends base {
+export class GrAutocomplete extends PolymerElement {
static get template() {
return htmlTemplate;
}
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 8dc23e2..ea5b5bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -88,7 +88,9 @@
background-color: var(--background-color);
color: var(--text-color);
display: flex;
- font-family: inherit;
+ font-family: var(--font-family, inherit);
+ /** Without this '.keyboard-focus' buttons will get bolded. */
+ font-weight: var(--font-weight-normal, inherit);
justify-content: center;
margin: var(--margin, 0);
min-width: var(--border, 0);
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 e7417f4..6b2e5c4 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
@@ -70,7 +70,7 @@
import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
import {getUserName} from '../../../utils/display-name-util';
import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addShortcut} from '../../../utils/dom-util';
+import {addGlobalShortcut} from '../../../utils/dom-util';
const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
@@ -84,8 +84,6 @@
@customElement('gr-comment-thread')
export class GrCommentThread extends PolymerElement {
- // KeyboardShortcutMixin Not used in this element rather other elements tests
-
static get template() {
return htmlTemplate;
}
@@ -239,10 +237,10 @@
override connectedCallback() {
super.connectedCallback();
this.cleanups.push(
- addShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
+ addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
);
this.cleanups.push(
- addShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
+ addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
);
this._getLoggedIn().then(loggedIn => {
this._showActions = loggedIn;
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 53b3bb9..154a045 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -30,7 +30,6 @@
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-comment_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {getRootElement} from '../../../scripts/rootElement';
import {appContext} from '../../../services/app-context';
import {customElement, observe, property} from '@polymer/decorators';
@@ -60,6 +59,7 @@
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';
const STORAGE_DEBOUNCE_INTERVAL = 400;
const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -100,11 +100,8 @@
};
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-comment')
-export class GrComment extends base {
+export class GrComment extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -273,12 +270,8 @@
@property({type: Boolean})
showPortedComment = false;
- get keyBindings() {
- return {
- 'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
- esc: '_handleEsc',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
private readonly restApiService = appContext.restApiService;
@@ -307,9 +300,21 @@
this._getIsAdmin().then(isAdmin => {
this._isAdmin = !!isAdmin;
});
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+ );
+ 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)
+ );
+ }
+ }
}
override disconnectedCallback() {
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
this.fireUpdateTask?.cancel();
this.storeTask?.cancel();
this.draftToastTask?.cancel();
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 53e62ff..00edc07 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
@@ -322,43 +322,43 @@
});
test('esc closes comment when text is empty', () => {
- pressAndReleaseKeyOn(element.textarea!, 27); // esc
+ pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
assert.isTrue(handleCancelStub.called);
});
test('ctrl+enter does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl'); // ctrl + enter
+ pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
assert.isFalse(handleSaveStub.called);
});
test('meta+enter does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'meta'); // meta + enter
+ pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
assert.isFalse(handleSaveStub.called);
});
test('ctrl+s does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl'); // ctrl + s
+ pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
assert.isFalse(handleSaveStub.called);
});
});
test('esc does not close comment that has content', () => {
- pressAndReleaseKeyOn(element.textarea!, 27); // esc
+ pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
assert.isFalse(handleCancelStub.called);
});
test('ctrl+enter saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl'); // ctrl + enter
+ pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
assert.isTrue(handleSaveStub.called);
});
test('meta+enter saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'meta'); // meta + enter
+ pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
assert.isTrue(handleSaveStub.called);
});
test('ctrl+s saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl'); // ctrl + s
+ pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
assert.isTrue(handleSaveStub.called);
});
});
@@ -1015,7 +1015,7 @@
element._messageText = '';
element.editing = true;
await flush();
- pressAndReleaseKeyOn(element.textarea!, 27); // esc
+ pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
await promise;
});
@@ -1093,7 +1093,12 @@
element._messageText = 'is that the horse from horsing around??';
element.editing = true;
await flush();
- pressAndReleaseKeyOn(element.textarea!.$.textarea.textarea, 83, 'ctrl'); // 'ctrl + s'
+ pressAndReleaseKeyOn(
+ element.textarea!.$.textarea.textarea,
+ 83,
+ 'ctrl',
+ 's'
+ );
await promise;
});
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 f4179f4..2b56de6 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -24,10 +24,10 @@
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-dropdown_html';
import {getBaseUrl} from '../../../utils/url-util';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
import {property, customElement, observe} from '@polymer/decorators';
+import {addShortcut, Key} from '../../../utils/dom-util';
const REL_NOOPENER = 'noopener';
const REL_EXTERNAL = 'external';
@@ -67,11 +67,8 @@
bold?: boolean;
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-dropdown')
-export class GrDropdown extends base {
+export class GrDropdown extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -121,14 +118,8 @@
@property({type: Array})
disabledIds: string[] = [];
- get keyBindings() {
- return {
- down: '_handleDown',
- 'enter space': '_handleEnter',
- tab: '_handleTab',
- up: '_handleUp',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
// Used within the tests so needs to be non-private.
cursor = new GrCursorManager();
@@ -139,15 +130,36 @@
this.cursor.focusOnMove = true;
}
+ override connectedCallback() {
+ super.connectedCallback();
+ this.cleanups.push(
+ addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.SPACE}, e => this._handleEnter(e))
+ );
+ }
+
override disconnectedCallback() {
this.cursor.unsetCursor();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
super.disconnectedCallback();
}
/**
* Handle the up key.
*/
- _handleUp(e: MouseEvent) {
+ _handleUp(e: Event) {
if (this.$.dropdown.opened) {
e.preventDefault();
e.stopPropagation();
@@ -160,7 +172,7 @@
/**
* Handle the down key.
*/
- _handleDown(e: MouseEvent) {
+ _handleDown(e: Event) {
if (this.$.dropdown.opened) {
e.preventDefault();
e.stopPropagation();
@@ -173,7 +185,7 @@
/**
* Handle the tab key.
*/
- _handleTab(e: MouseEvent) {
+ _handleTab(e: Event) {
if (this.$.dropdown.opened) {
// Tab in a native select is a no-op. Emulate this.
e.preventDefault();
@@ -184,7 +196,7 @@
/**
* Handle the enter key.
*/
- _handleEnter(e: MouseEvent) {
+ _handleEnter(e: Event) {
e.preventDefault();
e.stopPropagation();
if (this.$.dropdown.opened) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index e14d523..393f44e 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -170,18 +170,18 @@
test('down', () => {
const stub = sinon.stub(element.cursor, 'next');
assert.isFalse(element.$.dropdown.opened);
- MockInteractions.pressAndReleaseKeyOn(element, 40);
+ MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
assert.isTrue(element.$.dropdown.opened);
- MockInteractions.pressAndReleaseKeyOn(element, 40);
+ MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
assert.isTrue(stub.called);
});
test('up', () => {
const stub = sinon.stub(element.cursor, 'previous');
assert.isFalse(element.$.dropdown.opened);
- MockInteractions.pressAndReleaseKeyOn(element, 38);
+ MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
assert.isTrue(element.$.dropdown.opened);
- MockInteractions.pressAndReleaseKeyOn(element, 38);
+ MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
assert.isTrue(stub.called);
});
@@ -189,7 +189,7 @@
// Because enter and space are handled by the same fn, we need only to
// test one.
assert.isFalse(element.$.dropdown.opened);
- MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+ MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
assert.isTrue(element.$.dropdown.opened);
const el = queryAndAssert<HTMLAnchorElement>(
@@ -197,7 +197,7 @@
':not([hidden]) a'
);
const stub = sinon.stub(el, 'click');
- MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+ MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
assert.isTrue(stub.called);
});
});
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 13b195e..e0d1d15 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
@@ -20,17 +20,16 @@
import '../gr-button/gr-button';
import '../../shared/gr-autocomplete/gr-autocomplete';
import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {customElement, property} from '@polymer/decorators';
import {htmlTemplate} from './gr-editable-label_html';
import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PaperInputElementExt} from '../../../types/types';
-import {IronKeyboardEvent} from '../../../types/events';
import {
AutocompleteQuery,
GrAutocomplete,
} from '../gr-autocomplete/gr-autocomplete';
+import {addShortcut, Key} from '../../../utils/dom-util';
+import {queryAndAssert} from '../../../utils/common-util';
const AWAIT_MAX_ITERS = 10;
const AWAIT_STEP = 5;
@@ -47,11 +46,8 @@
};
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-editable-label')
-export class GrEditableLabel extends base {
+export class GrEditableLabel extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -106,11 +102,23 @@
this._ensureAttribute('tabindex', '0');
}
- get keyBindings() {
- return {
- enter: '_handleEnter',
- esc: '_handleEsc',
- };
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+ );
}
_usePlaceholder(value?: string, placeholder?: string) {
@@ -204,20 +212,24 @@
this.getGrAutocomplete()) as HTMLInputElement;
}
- _handleEnter(event: IronKeyboardEvent) {
- const e = event.detail.keyboardEvent;
- const target = (dom(e) as EventApi).rootTarget;
- if (target === this._nativeInput) {
- e.preventDefault();
+ _handleEnter(event: KeyboardEvent) {
+ const inputContainer = queryAndAssert(this, '.inputContainer');
+ const isEventFromInput = event
+ .composedPath()
+ .some(element => element === inputContainer);
+ if (isEventFromInput) {
+ event.preventDefault();
this._save();
}
}
- _handleEsc(event: IronKeyboardEvent) {
- const e = event.detail.keyboardEvent;
- const target = (dom(e) as EventApi).rootTarget;
- if (target === this._nativeInput) {
- e.preventDefault();
+ _handleEsc(event: KeyboardEvent) {
+ const inputContainer = queryAndAssert(this, '.inputContainer');
+ const isEventFromInput = event
+ .composedPath()
+ .some(element => element === inputContainer);
+ if (isEventFromInput) {
+ event.preventDefault();
this._cancel();
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
index 3e217f0..b6bb87b 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
@@ -101,7 +101,7 @@
element._inputText = 'new text';
// Press enter:
- MockInteractions.keyDownOn(input, 13);
+ MockInteractions.keyDownOn(input, 13, null, 'Enter');
flush();
assert.isTrue(editedSpy.called);
@@ -122,7 +122,7 @@
element._inputText = 'new text';
// Press enter:
- MockInteractions.tap(element.$.saveBtn, 13);
+ MockInteractions.tap(element.$.saveBtn, 13, null, 'Enter');
flush();
assert.isTrue(editedSpy.called);
@@ -143,7 +143,7 @@
element._inputText = 'new text';
// Press escape:
- MockInteractions.keyDownOn(input, 27);
+ MockInteractions.keyDownOn(input, 27, null, 'Escape');
flush();
assert.isFalse(editedSpy.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index da1a782..1a6239f 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -160,6 +160,8 @@
<g id="feedback"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></g>
<!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=description -->
<g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
+ <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
+ <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
</defs>
</svg>
</iron-iconset-svg>`;
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-interface.ts
index 63576c2..c2b0269 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-interface.ts
@@ -149,7 +149,6 @@
createDefaultDiffPrefs,
createDefaultEditPrefs,
createDefaultPreferences,
- DiffViewMode,
HttpMethod,
ReviewerState,
} from '../../../constants/constants';
@@ -159,8 +158,6 @@
import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
const MAX_PROJECT_RESULTS = 25;
-// This value is somewhat arbitrary and not based on research or calculations.
-const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
const Requests = {
SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -977,13 +974,6 @@
return res;
}
const prefInfo = res as unknown as PreferencesInfo;
- if (this._isNarrowScreen()) {
- // Note that this can be problematic, because the diff will stay
- // unified even after increasing the window width.
- prefInfo.default_diff_view = DiffViewMode.UNIFIED;
- } else {
- prefInfo.default_diff_view = prefInfo.diff_view;
- }
return prefInfo;
});
}
@@ -1019,10 +1009,6 @@
});
}
- _isNarrowScreen() {
- return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
- }
-
getChanges(
changesPerPage?: number,
query?: string,
@@ -1145,7 +1131,8 @@
_getChangesOptionsHex() {
if (
window.DEFAULT_DETAIL_HEXES &&
- window.DEFAULT_DETAIL_HEXES.dashboardPage
+ window.DEFAULT_DETAIL_HEXES.dashboardPage &&
+ !this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)
) {
return window.DEFAULT_DETAIL_HEXES.dashboardPage;
}
@@ -1153,6 +1140,9 @@
ListChangesOption.LABELS,
ListChangesOption.DETAILED_ACCOUNTS,
];
+ if (this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+ options.push(ListChangesOption.SUBMIT_REQUIREMENTS);
+ }
return listChangesOptionsToHex(...options);
}
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-interface_test.js
index a60a1ef..6a02985 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-interface_test.js
@@ -333,26 +333,23 @@
stub.lastCall.args[0].errFn({});
});
- const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+ const preferenceSetup = function(testJSON, loggedIn) {
sinon.stub(element, 'getLoggedIn')
.callsFake(() => Promise.resolve(loggedIn));
- sinon.stub(element, '_isNarrowScreen').callsFake(() => smallScreen);
sinon.stub(
element._restApiHelper,
'fetchCacheURL')
.callsFake(() => Promise.resolve(testJSON));
};
- test('getPreferences returns correctly on small screens logged in',
+ test('getPreferences returns correctly logged in',
() => {
const testJSON = {diff_view: 'SIDE_BY_SIDE'};
const loggedIn = true;
- const smallScreen = true;
- preferenceSetup(testJSON, loggedIn, smallScreen);
+ preferenceSetup(testJSON, loggedIn);
return element.getPreferences().then(obj => {
- assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
});
});
@@ -361,12 +358,10 @@
() => {
const testJSON = {diff_view: 'UNIFIED_DIFF'};
const loggedIn = true;
- const smallScreen = false;
- preferenceSetup(testJSON, loggedIn, smallScreen);
+ preferenceSetup(testJSON, loggedIn);
return element.getPreferences().then(obj => {
- assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
assert.equal(obj.diff_view, 'UNIFIED_DIFF');
});
});
@@ -375,12 +370,10 @@
() => {
const testJSON = {diff_view: 'UNIFIED_DIFF'};
const loggedIn = false;
- const smallScreen = false;
- preferenceSetup(testJSON, loggedIn, smallScreen);
+ preferenceSetup(testJSON, loggedIn);
return element.getPreferences().then(obj => {
- assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
});
});
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 434da1f..9e6b42a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -17,13 +17,11 @@
import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import '../gr-cursor-manager/gr-cursor-manager';
import '../gr-overlay/gr-overlay';
-import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../../styles/shared-styles';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-textarea_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {appContext} from '../../../services/app-context';
import {customElement, property} from '@polymer/decorators';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -33,7 +31,7 @@
Item,
ItemSelectedEvent,
} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {IronKeyboardEvent} from '../../../types/events';
+import {addShortcut, Key} from '../../../utils/dom-util';
const MAX_ITEMS_DROPDOWN = 10;
@@ -85,11 +83,8 @@
}
}
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
@customElement('gr-textarea')
-export class GrTextarea extends base {
+export class GrTextarea extends PolymerElement {
static get template() {
return htmlTemplate;
}
@@ -150,21 +145,39 @@
disableEnterKeyForSelectingEmoji = false;
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- tab: '_handleTabKey',
- enter: '_handleEnterByKey',
- up: '_handleUpKey',
- down: '_handleDownKey',
- };
- }
+ /** Called in disconnectedCallback. */
+ private cleanups: (() => void)[] = [];
constructor() {
super();
this.reporting = appContext.reportingService;
}
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const cleanup of this.cleanups) cleanup();
+ this.cleanups = [];
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.cleanups.push(
+ addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
+ );
+ this.cleanups.push(
+ addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
+ );
+ }
+
override ready() {
super.ready();
if (this.monospace) {
@@ -238,16 +251,11 @@
this._setEmoji(this.$.emojiSuggestions.getCurrentText());
}
- _handleEnterByKey(e: IronKeyboardEvent) {
+ _handleEnterByKey(e: KeyboardEvent) {
// Enter should have newline behavior if the picker is closed or if the user
// has only typed ':'. Also make sure that shortcuts aren't clobbered.
if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
- if (
- !e.detail.keyboardEvent?.metaKey &&
- !e.detail.keyboardEvent?.ctrlKey
- ) {
- this.indent(e);
- }
+ this.indent(e);
return;
}
@@ -420,7 +428,7 @@
);
}
- private indent(e: IronKeyboardEvent): void {
+ private indent(e: KeyboardEvent): void {
if (!document.queryCommandSupported('insertText')) {
return;
}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 7e59692..318c720 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -20,7 +20,6 @@
import {GrTextarea} from './gr-textarea';
import {html} from '@polymer/polymer/lib/utils/html-tag';
import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {IronKeyboardEvent} from '../../../types/events';
import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
const basicFixture = fixtureFromElement('gr-textarea');
@@ -238,34 +237,12 @@
const indentCommand = sinon.stub(document, 'execCommand');
element.$.textarea.value = ' a';
element._handleEnterByKey(
- new CustomEvent('keydown', {
- detail: {keyboardEvent: {keyCode: 13}},
- }) as IronKeyboardEvent
+ new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13})
);
await flush();
assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n ']);
});
- test('ctrl+enter and meta+enter do not indent', async () => {
- const indentCommand = sinon.stub(document, 'execCommand');
- element.$.textarea.value = ' a';
- element._handleEnterByKey(
- new CustomEvent('keydown', {
- detail: {keyboardEvent: {keyCode: 13, ctrlKey: true}},
- }) as IronKeyboardEvent
- );
- await flush();
- assert.isTrue(indentCommand.notCalled);
-
- element._handleEnterByKey(
- new CustomEvent('keydown', {
- detail: {keyboardEvent: {keyCode: 13, metaKey: true}},
- }) as IronKeyboardEvent
- );
- await flush();
- assert.isTrue(indentCommand.notCalled);
- });
-
test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
element.$.emojiSuggestions.dispatchEvent(
@@ -301,38 +278,78 @@
test('escape key', () => {
const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 27,
+ null,
+ 'Escape'
+ );
assert.isFalse(resetSpy.called);
setupDropdown();
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 27,
+ null,
+ 'Escape'
+ );
assert.isTrue(resetSpy.called);
assert.isFalse(!element.$.emojiSuggestions.isHidden);
});
test('up key', () => {
const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 38,
+ null,
+ 'ArrowUp'
+ );
assert.isFalse(upSpy.called);
setupDropdown();
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 38,
+ null,
+ 'ArrowUp'
+ );
assert.isTrue(upSpy.called);
});
test('down key', () => {
const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 40,
+ null,
+ 'ArrowDown'
+ );
assert.isFalse(downSpy.called);
setupDropdown();
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 40,
+ null,
+ 'ArrowDown'
+ );
assert.isTrue(downSpy.called);
});
test('enter key', () => {
const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 13,
+ null,
+ 'Enter'
+ );
assert.isFalse(enterSpy.called);
setupDropdown();
- MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+ MockInteractions.pressAndReleaseKeyOn(
+ element.$.textarea,
+ 13,
+ null,
+ 'Enter'
+ );
assert.isTrue(enterSpy.called);
flush();
assert.equal(element.text, '💯');
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index 793e5d6..fd4da4f 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -159,7 +159,12 @@
}
private addTargetEventListeners() {
- this._target?.addEventListener('mouseenter', this.debounceShow);
+ // We intentionally listen on 'mousemove' instead of 'mouseenter', because
+ // otherwise the target appearing under the mouse cursor would also
+ // trigger the hovercard, which can annoying for the user, for example
+ // when added reviewer chips appear in the reply dialog via keyboard
+ // interaction.
+ this._target?.addEventListener('mousemove', this.debounceShow);
this._target?.addEventListener('focus', this.debounceShow);
this._target?.addEventListener('mouseleave', this.debounceHide);
this._target?.addEventListener('blur', this.debounceHide);
@@ -167,7 +172,7 @@
}
private removeTargetEventListeners() {
- this._target?.removeEventListener('mouseenter', this.debounceShow);
+ this._target?.removeEventListener('mousemove', this.debounceShow);
this._target?.removeEventListener('focus', this.debounceShow);
this._target?.removeEventListener('mouseleave', this.debounceHide);
this._target?.removeEventListener('blur', this.debounceHide);
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
index bd12789..dd86b38 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -130,12 +130,12 @@
test('card is scheduled to show on enter and hides on leave', async () => {
const button = document.querySelector('button');
const enterPromise = mockPromise();
- button!.addEventListener('mouseenter', () => enterPromise.resolve());
+ button!.addEventListener('mousemove', () => enterPromise.resolve());
const leavePromise = mockPromise();
button!.addEventListener('mouseleave', () => leavePromise.resolve());
assert.isFalse(element._isShowing);
- button!.dispatchEvent(new CustomEvent('mouseenter'));
+ button!.dispatchEvent(new CustomEvent('mousemove'));
await enterPromise;
await flush();
@@ -158,12 +158,12 @@
const button = document.querySelector('button');
const enterPromise = mockPromise();
const clickPromise = mockPromise();
- button!.addEventListener('mouseenter', () => enterPromise.resolve());
+ button!.addEventListener('mousemove', () => enterPromise.resolve());
button!.addEventListener('click', () => clickPromise.resolve());
assert.isFalse(element._isShowing);
- button!.dispatchEvent(new CustomEvent('mouseenter'));
+ button!.dispatchEvent(new CustomEvent('mousemove'));
await enterPromise;
await flush();
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 02cfa9f..f133c116 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -14,12 +14,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
import {property} from '@polymer/decorators';
import {PolymerElement} from '@polymer/polymer';
import {check, Constructor} from '../../utils/common-util';
-import {IronKeyboardEvent} from '../../types/events';
import {appContext} from '../../services/app-context';
import {
Shortcut,
@@ -27,7 +24,6 @@
SPECIAL_SHORTCUT,
} from '../../services/shortcuts/shortcuts-config';
import {
- ComboKey,
SectionView,
ShortcutListener,
} from '../../services/shortcuts/shortcuts-service';
@@ -40,18 +36,7 @@
SectionView,
};
-interface IronA11yKeysMixinConstructor {
- // Note: this is needed to have same interface as other mixins
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- new (...args: any[]): IronA11yKeysBehavior;
-}
-/**
- * @polymer
- * @mixinFunction
- */
-const InternalKeyboardShortcutMixin = <
- T extends Constructor<PolymerElement> & IronA11yKeysMixinConstructor
->(
+export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
superClass: T
) => {
/**
@@ -59,14 +44,10 @@
* @mixinClass
*/
class Mixin extends superClass {
- @property({type: Object})
- _shortcut_go_table: Map<string, string> = new Map<string, string>();
-
- @property({type: Object})
- _shortcut_v_table: Map<string, string> = new Map<string, string>();
-
+ // This enables `Shortcut` to be used in the html template.
Shortcut = Shortcut;
+ // This enables `ShortcutSection` to be used in the html template.
ShortcutSection = ShortcutSection;
private readonly shortcuts = appContext.shortcutsService;
@@ -88,30 +69,6 @@
/** Are shortcuts currently enabled? True only when element is visible. */
private bindingsEnabled = false;
- _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
- const bindings = this.shortcuts.getBindingsForShortcut(shortcut);
- if (!bindings) {
- return;
- }
- if (bindings[0] === SPECIAL_SHORTCUT.DOC_ONLY) {
- return;
- }
- if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
- bindings
- .slice(1)
- .forEach(binding => this._shortcut_go_table.set(binding, handler));
- } else if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
- // for each binding added with the go/v key, we set the handler to be
- // handleVKeyAction. handleVKeyAction then looks up in th
- // shortcut_table to see what the relevant handler should be
- bindings
- .slice(1)
- .forEach(binding => this._shortcut_v_table.set(binding, handler));
- } else {
- this.addOwnKeyBinding(bindings.join(' '), handler);
- }
- }
-
override connectedCallback() {
super.connectedCallback();
this.createVisibilityObserver();
@@ -157,28 +114,7 @@
if (this.bindingsEnabled) return;
this.bindingsEnabled = true;
- const shortcuts = new Map<string, string>(
- Object.entries(this.keyboardShortcuts())
- );
- this.shortcuts.attachHost(this, shortcuts);
-
- for (const [key, value] of shortcuts.entries()) {
- this._addOwnKeyBindings(key as Shortcut, value);
- }
-
- // If any of the shortcuts utilized GO_KEY, then they are handled
- // directly by this behavior.
- if (this._shortcut_go_table.size > 0) {
- this._shortcut_go_table.forEach((_, key) => {
- this.addOwnKeyBinding(key, '_handleGoAction');
- });
- }
-
- if (this._shortcut_v_table.size > 0) {
- this._shortcut_v_table.forEach((_, key) => {
- this.addOwnKeyBinding(key, '_handleVAction');
- });
- }
+ this.shortcuts.attachHost(this, this.keyboardShortcuts());
}
/**
@@ -189,76 +125,22 @@
private disableBindings() {
if (!this.bindingsEnabled) return;
this.bindingsEnabled = false;
- if (this.shortcuts.detachHost(this)) {
- this.removeOwnKeyBindings();
- }
+ this.shortcuts.detachHost(this);
}
private hasKeyboardShortcuts() {
- return Object.entries(this.keyboardShortcuts()).length > 0;
+ return this.keyboardShortcuts().length > 0;
}
- keyboardShortcuts() {
- return {};
- }
-
- _handleVAction(e: IronKeyboardEvent) {
- if (
- !this.shortcuts.isInSpecificComboKeyMode(ComboKey.V) ||
- !this._shortcut_v_table.has(e.detail.key) ||
- this.shortcuts.shouldSuppress(e)
- ) {
- return;
- }
- e.preventDefault();
- const handler = this._shortcut_v_table.get(e.detail.key);
- if (handler) {
- // TODO(TS): should fix this
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (this as any)[handler](e);
- }
- }
-
- _handleGoAction(e: IronKeyboardEvent) {
- if (
- !this.shortcuts.isInSpecificComboKeyMode(ComboKey.G) ||
- !this._shortcut_go_table.has(e.detail.key) ||
- this.shortcuts.shouldSuppress(e)
- ) {
- return;
- }
- e.preventDefault();
- const handler = this._shortcut_go_table.get(e.detail.key);
- if (handler) {
- // TODO(TS): should fix this
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (this as any)[handler](e);
- }
+ keyboardShortcuts(): ShortcutListener[] {
+ return [];
}
}
return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
};
-// The following doesn't work (IronA11yKeysBehavior crashes):
-// const KeyboardShortcutMixin = superClass => {
-// class Mixin extends mixinBehaviors([IronA11yKeysBehavior], superClass) {
-// ...
-// }
-// return Mixin;
-// }
-// This is a workaround
-export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
- superClass: T
-): T & Constructor<KeyboardShortcutMixinInterface> =>
- InternalKeyboardShortcutMixin(
- // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
- // which will fail the type check due to missing IronA11yKeysBehavior interface
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- mixinBehaviors([IronA11yKeysBehavior], superClass) as any
- );
-
/** The interface corresponding to KeyboardShortcutMixin */
export interface KeyboardShortcutMixinInterface {
- keyboardShortcuts(): {[key: string]: string | null};
+ keyboardShortcuts(): ShortcutListener[];
}
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 2ad4e79..281a1eb 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -6,7 +6,6 @@
"@polymer/decorators": "^3.0.0",
"@polymer/font-roboto-local": "^3.0.2",
"@polymer/iron-a11y-announcer": "^3.1.0",
- "@polymer/iron-a11y-keys-behavior": "^3.0.1",
"@polymer/iron-autogrow-textarea": "^3.0.3",
"@polymer/iron-dropdown": "^3.0.1",
"@polymer/iron-fit-behavior": "^3.1.0",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 3a6f7c5..b9c4f49 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -28,6 +28,7 @@
import {UserService} from './user/user-service';
import {CommentsService} from './comments/comments-service';
import {ShortcutsService} from './shortcuts/shortcuts-service';
+import {BrowserService} from './browser/browser-service';
type ServiceName = keyof AppContext;
type ServiceCreator<T> = () => T;
@@ -84,5 +85,6 @@
configService: () => new ConfigService(),
userService: () => new UserService(appContext.restApiService),
shortcutsService: () => new ShortcutsService(appContext.reportingService),
+ browserService: () => new BrowserService(),
});
}
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index e5828d6..47da722 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -27,6 +27,7 @@
import {UserService} from './user/user-service';
import {CommentsService} from './comments/comments-service';
import {ShortcutsService} from './shortcuts/shortcuts-service';
+import {BrowserService} from './browser/browser-service';
export interface AppContext {
flagsService: FlagsService;
@@ -41,6 +42,7 @@
storageService: StorageService;
configService: ConfigService;
userService: UserService;
+ browserService: BrowserService;
shortcutsService: ShortcutsService;
}
diff --git a/polygerrit-ui/app/services/browser/browser-model.ts b/polygerrit-ui/app/services/browser/browser-model.ts
new file mode 100644
index 0000000..db790f6
--- /dev/null
+++ b/polygerrit-ui/app/services/browser/browser-model.ts
@@ -0,0 +1,74 @@
+/**
+ * @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 {BehaviorSubject, Observable, combineLatest} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {preferenceDiffViewMode$} from '../user/user-model';
+import {DiffViewMode} from '../../api/diff';
+
+// This value is somewhat arbitrary and not based on research or calculations.
+const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
+
+interface BrowserState {
+ /**
+ * We maintain the screen width in the state so that the app can react to
+ * changes in the width such as automatically changing to unified diff view
+ */
+ screenWidth?: number;
+}
+
+const initialState: BrowserState = {};
+
+// Mutable for testing
+let privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+ privateState$ = new BehaviorSubject(initialState);
+}
+
+export function _testOnly_setState(state: BrowserState) {
+ privateState$.next(state);
+}
+
+export function _testOnly_getState() {
+ return privateState$.getValue();
+}
+
+export const viewState$: Observable<BrowserState> = privateState$;
+
+export function updateStateScreenWidth(screenWidth: number) {
+ privateState$.next({...privateState$.getValue(), screenWidth});
+}
+
+export const isScreenTooSmall$ = viewState$.pipe(
+ map(
+ state =>
+ !!state.screenWidth &&
+ state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
+ ),
+ distinctUntilChanged()
+);
+
+export const diffViewMode$: Observable<DiffViewMode> = combineLatest([
+ isScreenTooSmall$,
+ preferenceDiffViewMode$,
+]).pipe(
+ map(([isScreenTooSmall, preferenceDiffViewMode]) => {
+ if (isScreenTooSmall) return DiffViewMode.UNIFIED;
+ else return preferenceDiffViewMode;
+ }, distinctUntilChanged())
+);
diff --git a/polygerrit-ui/app/services/browser/browser-service.ts b/polygerrit-ui/app/services/browser/browser-service.ts
new file mode 100644
index 0000000..d98f8f7
--- /dev/null
+++ b/polygerrit-ui/app/services/browser/browser-service.ts
@@ -0,0 +1,29 @@
+import {updateStateScreenWidth} from './browser-model';
+
+/**
+ * @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.
+ */
+
+export class BrowserService {
+ /* Observer the screen width so that the app can react to changes to it */
+ observeWidth() {
+ return new ResizeObserver(entries => {
+ entries.forEach(entry => {
+ updateStateScreenWidth(entry.contentRect.width);
+ });
+ });
+ }
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index bd004d7..3c9e058 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -16,6 +16,8 @@
*/
/** Enum for all special shortcuts */
+import {ComboKey, Key, Modifier, Binding} from '../../utils/dom-util';
+
export enum SPECIAL_SHORTCUT {
DOC_ONLY = 'DOC_ONLY',
GO_KEY = 'GO_KEY',
@@ -115,7 +117,7 @@
export interface ShortcutHelpItem {
shortcut: Shortcut;
text: string;
- bindings: string[];
+ bindings: Binding[];
}
export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
@@ -124,8 +126,8 @@
shortcut: Shortcut,
section: ShortcutSection,
text: string,
- binding: string,
- ...moreBindings: string[]
+ binding: Binding,
+ ...moreBindings: Binding[]
) {
if (!config.has(section)) {
config.set(section, []);
@@ -136,417 +138,388 @@
}
}
-describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', '/');
+describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', {key: '/'});
describe(
Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
ShortcutSection.EVERYWHERE,
'Show this dialog',
- '?'
+ {key: '?'}
);
describe(
Shortcut.GO_TO_USER_DASHBOARD,
ShortcutSection.EVERYWHERE,
'Go to User Dashboard',
- SPECIAL_SHORTCUT.GO_KEY,
- 'i'
+ {key: 'i', combo: ComboKey.G}
);
describe(
Shortcut.GO_TO_OPENED_CHANGES,
ShortcutSection.EVERYWHERE,
'Go to Opened Changes',
- SPECIAL_SHORTCUT.GO_KEY,
- 'o'
+ {key: 'o', combo: ComboKey.G}
);
describe(
Shortcut.GO_TO_MERGED_CHANGES,
ShortcutSection.EVERYWHERE,
'Go to Merged Changes',
- SPECIAL_SHORTCUT.GO_KEY,
- 'm'
+ {key: 'm', combo: ComboKey.G}
);
describe(
Shortcut.GO_TO_ABANDONED_CHANGES,
ShortcutSection.EVERYWHERE,
'Go to Abandoned Changes',
- SPECIAL_SHORTCUT.GO_KEY,
- 'a'
+ {key: 'a', combo: ComboKey.G}
);
describe(
Shortcut.GO_TO_WATCHED_CHANGES,
ShortcutSection.EVERYWHERE,
'Go to Watched Changes',
- SPECIAL_SHORTCUT.GO_KEY,
- 'w'
+ {key: 'w', combo: ComboKey.G}
);
describe(
Shortcut.CURSOR_NEXT_CHANGE,
ShortcutSection.ACTIONS,
'Select next change',
- 'j'
+ {key: 'j'}
);
describe(
Shortcut.CURSOR_PREV_CHANGE,
ShortcutSection.ACTIONS,
'Select previous change',
- 'k'
+ {key: 'k'}
);
describe(
Shortcut.OPEN_CHANGE,
ShortcutSection.ACTIONS,
'Show selected change',
- 'o'
+ {key: 'o'}
);
describe(
Shortcut.NEXT_PAGE,
ShortcutSection.ACTIONS,
'Go to next page',
- 'n',
- ']'
+ {key: 'n'},
+ {key: ']'}
);
describe(
Shortcut.PREV_PAGE,
ShortcutSection.ACTIONS,
'Go to previous page',
- 'p',
- '['
+ {key: 'p'},
+ {key: '['}
);
describe(
Shortcut.OPEN_REPLY_DIALOG,
ShortcutSection.ACTIONS,
'Open reply dialog to publish comments and add reviewers',
- 'a:keyup'
+ {key: 'a'}
);
describe(
Shortcut.OPEN_DOWNLOAD_DIALOG,
ShortcutSection.ACTIONS,
'Open download overlay',
- 'd:keyup'
+ {key: 'd'}
);
describe(
Shortcut.EXPAND_ALL_MESSAGES,
ShortcutSection.ACTIONS,
'Expand all messages',
- 'x'
+ {key: 'x'}
);
describe(
Shortcut.COLLAPSE_ALL_MESSAGES,
ShortcutSection.ACTIONS,
'Collapse all messages',
- 'z'
+ {key: 'z'}
);
describe(
Shortcut.REFRESH_CHANGE,
ShortcutSection.ACTIONS,
'Reload the change at the latest patch',
- 'shift+r:keyup'
+ {key: 'R'}
);
describe(
Shortcut.TOGGLE_CHANGE_REVIEWED,
ShortcutSection.ACTIONS,
'Mark/unmark change as reviewed',
- 'r:keyup'
+ {key: 'r'}
);
describe(
Shortcut.TOGGLE_FILE_REVIEWED,
ShortcutSection.ACTIONS,
'Toggle review flag on selected file',
- 'r:keyup'
+ {key: 'r'}
);
describe(
Shortcut.REFRESH_CHANGE_LIST,
ShortcutSection.ACTIONS,
'Refresh list of changes',
- 'shift+r:keyup'
+ {key: 'R'}
);
describe(
Shortcut.TOGGLE_CHANGE_STAR,
ShortcutSection.ACTIONS,
'Star/unstar change',
- 's:keydown'
+ {key: 's'}
);
describe(
Shortcut.OPEN_SUBMIT_DIALOG,
ShortcutSection.ACTIONS,
'Open submit dialog',
- 'shift+s'
+ {key: 'S'}
);
describe(
Shortcut.TOGGLE_ATTENTION_SET,
ShortcutSection.ACTIONS,
'Toggle attention set status',
- 'shift+t'
+ {key: 'T'}
);
-describe(
- Shortcut.EDIT_TOPIC,
- ShortcutSection.ACTIONS,
- 'Add a change topic',
- 't'
-);
+describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic', {
+ key: 't',
+});
describe(
Shortcut.DIFF_AGAINST_BASE,
ShortcutSection.DIFFS,
'Diff against base',
- SPECIAL_SHORTCUT.V_KEY,
- 'down',
- 's'
+ {key: Key.DOWN, combo: ComboKey.V},
+ {key: 's', combo: ComboKey.V}
);
describe(
Shortcut.DIFF_AGAINST_LATEST,
ShortcutSection.DIFFS,
'Diff against latest patchset',
- SPECIAL_SHORTCUT.V_KEY,
- 'up',
- 'w'
+ {key: Key.UP, combo: ComboKey.V},
+ {key: 'w', combo: ComboKey.V}
);
describe(
Shortcut.DIFF_BASE_AGAINST_LEFT,
ShortcutSection.DIFFS,
'Diff base against left',
- SPECIAL_SHORTCUT.V_KEY,
- 'left',
- 'a'
+ {key: Key.LEFT, combo: ComboKey.V},
+ {key: 'a', combo: ComboKey.V}
);
describe(
Shortcut.DIFF_RIGHT_AGAINST_LATEST,
ShortcutSection.DIFFS,
'Diff right against latest',
- SPECIAL_SHORTCUT.V_KEY,
- 'right',
- 'd'
+ {key: Key.RIGHT, combo: ComboKey.V},
+ {key: 'd', combo: ComboKey.V}
);
describe(
Shortcut.DIFF_BASE_AGAINST_LATEST,
ShortcutSection.DIFFS,
'Diff base against latest',
- SPECIAL_SHORTCUT.V_KEY,
- 'b'
+ {key: 'b', combo: ComboKey.V}
);
describe(
Shortcut.NEXT_LINE,
ShortcutSection.DIFFS,
'Go to next line',
- 'j',
- 'down'
+ {key: 'j'},
+ {key: Key.DOWN}
);
describe(
Shortcut.PREV_LINE,
ShortcutSection.DIFFS,
'Go to previous line',
- 'k',
- 'up'
+ {key: 'k'},
+ {key: Key.UP}
);
describe(
Shortcut.VISIBLE_LINE,
ShortcutSection.DIFFS,
'Move cursor to currently visible code',
- '.'
+ {key: '.'}
);
-describe(
- Shortcut.NEXT_CHUNK,
- ShortcutSection.DIFFS,
- 'Go to next diff chunk',
- 'n'
-);
+describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk', {
+ key: 'n',
+});
describe(
Shortcut.PREV_CHUNK,
ShortcutSection.DIFFS,
'Go to previous diff chunk',
- 'p'
+ {key: 'p'}
);
describe(
Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
ShortcutSection.DIFFS,
'Toggle all diff context',
- 'shift+x'
+ {key: 'X'}
);
describe(
Shortcut.NEXT_COMMENT_THREAD,
ShortcutSection.DIFFS,
'Go to next comment thread',
- 'shift+n'
+ {key: 'N'}
);
describe(
Shortcut.PREV_COMMENT_THREAD,
ShortcutSection.DIFFS,
'Go to previous comment thread',
- 'shift+p'
+ {key: 'P'}
);
describe(
Shortcut.EXPAND_ALL_COMMENT_THREADS,
ShortcutSection.DIFFS,
'Expand all comment threads',
- SPECIAL_SHORTCUT.DOC_ONLY,
- 'e'
+ {key: 'e', docOnly: true}
);
describe(
Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
ShortcutSection.DIFFS,
'Collapse all comment threads',
- SPECIAL_SHORTCUT.DOC_ONLY,
- 'shift+e'
+ {key: 'E', docOnly: true}
);
describe(
Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
ShortcutSection.DIFFS,
'Hide/Display all comment threads',
- 'h'
+ {key: 'h'}
);
-describe(
- Shortcut.LEFT_PANE,
- ShortcutSection.DIFFS,
- 'Select left pane',
- 'shift+left'
-);
-describe(
- Shortcut.RIGHT_PANE,
- ShortcutSection.DIFFS,
- 'Select right pane',
- 'shift+right'
-);
+describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane', {
+ key: Key.LEFT,
+ modifiers: [Modifier.SHIFT_KEY],
+});
+describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane', {
+ key: Key.RIGHT,
+ modifiers: [Modifier.SHIFT_KEY],
+});
describe(
Shortcut.TOGGLE_LEFT_PANE,
ShortcutSection.DIFFS,
'Hide/show left diff',
- 'shift+a'
+ {key: 'A'}
);
-describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', 'c');
+describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
+ key: 'c',
+});
describe(
Shortcut.SAVE_COMMENT,
ShortcutSection.DIFFS,
'Save comment',
- 'ctrl+enter',
- 'meta+enter',
- 'ctrl+s',
- 'meta+s'
+ {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+ {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+ {key: 's', modifiers: [Modifier.CTRL_KEY]},
+ {key: 's', modifiers: [Modifier.META_KEY]}
);
describe(
Shortcut.OPEN_DIFF_PREFS,
ShortcutSection.DIFFS,
'Show diff preferences',
- ','
+ {key: ','}
);
describe(
Shortcut.TOGGLE_DIFF_REVIEWED,
ShortcutSection.DIFFS,
'Mark/unmark file as reviewed',
- 'r:keyup'
+ {key: 'r'}
);
describe(
Shortcut.TOGGLE_DIFF_MODE,
ShortcutSection.DIFFS,
'Toggle unified/side-by-side diff',
- 'm:keyup'
+ {key: 'm'}
);
describe(
Shortcut.NEXT_UNREVIEWED_FILE,
ShortcutSection.DIFFS,
'Mark file as reviewed and go to next unreviewed file',
- 'shift+m'
+ {key: 'M'}
);
-describe(
- Shortcut.TOGGLE_BLAME,
- ShortcutSection.DIFFS,
- 'Toggle blame',
- 'b:keyup'
-);
-describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', 'f');
-describe(
- Shortcut.NEXT_FILE,
- ShortcutSection.NAVIGATION,
- 'Go to next file',
- ']'
-);
+describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame', {
+ key: 'b',
+});
+describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', {
+ key: 'f',
+});
+describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file', {
+ key: ']',
+});
describe(
Shortcut.PREV_FILE,
ShortcutSection.NAVIGATION,
'Go to previous file',
- '['
+ {key: '['}
);
describe(
Shortcut.NEXT_FILE_WITH_COMMENTS,
ShortcutSection.NAVIGATION,
'Go to next file that has comments',
- 'shift+j'
+ {key: 'J'}
);
describe(
Shortcut.PREV_FILE_WITH_COMMENTS,
ShortcutSection.NAVIGATION,
'Go to previous file that has comments',
- 'shift+k'
+ {key: 'K'}
);
describe(
Shortcut.OPEN_FIRST_FILE,
ShortcutSection.NAVIGATION,
'Go to first file',
- ']'
+ {key: ']'}
);
describe(
Shortcut.OPEN_LAST_FILE,
ShortcutSection.NAVIGATION,
'Go to last file',
- '['
+ {key: '['}
);
describe(
Shortcut.UP_TO_DASHBOARD,
ShortcutSection.NAVIGATION,
'Up to dashboard',
- 'u'
+ {key: 'u'}
);
-describe(
- Shortcut.UP_TO_CHANGE,
- ShortcutSection.NAVIGATION,
- 'Up to change',
- 'u'
-);
+describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change', {
+ key: 'u',
+});
describe(
Shortcut.CURSOR_NEXT_FILE,
ShortcutSection.FILE_LIST,
'Select next file',
- 'j',
- 'down'
+ {key: 'j'},
+ {key: Key.DOWN}
);
describe(
Shortcut.CURSOR_PREV_FILE,
ShortcutSection.FILE_LIST,
'Select previous file',
- 'k',
- 'up'
+ {key: 'k'},
+ {key: Key.UP}
);
describe(
Shortcut.OPEN_FILE,
ShortcutSection.FILE_LIST,
'Go to selected file',
- 'o',
- 'enter'
+ {key: 'o'},
+ {key: Key.ENTER}
);
describe(
Shortcut.TOGGLE_ALL_INLINE_DIFFS,
ShortcutSection.FILE_LIST,
'Show/hide all inline diffs',
- 'shift+i'
+ {key: 'I'}
);
describe(
Shortcut.TOGGLE_INLINE_DIFF,
ShortcutSection.FILE_LIST,
'Show/hide selected inline diff',
- 'i'
+ {key: 'i'}
);
describe(
Shortcut.SEND_REPLY,
ShortcutSection.REPLY_DIALOG,
'Send reply',
- SPECIAL_SHORTCUT.DOC_ONLY,
- 'ctrl+enter',
- 'meta+enter'
+ {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY], docOnly: true},
+ {key: Key.ENTER, modifiers: [Modifier.META_KEY], docOnly: true}
);
describe(
Shortcut.EMOJI_DROPDOWN,
ShortcutSection.REPLY_DIALOG,
'Emoji dropdown',
- SPECIAL_SHORTCUT.DOC_ONLY,
- ':'
+ {key: ':', docOnly: true}
);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index edc31a4..a26fa08 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -19,27 +19,40 @@
Shortcut,
ShortcutHelpItem,
ShortcutSection,
- SPECIAL_SHORTCUT,
} from './shortcuts-config';
import {disableShortcuts$} from '../user/user-model';
-import {IronKeyboardEvent, isIronKeyboardEvent} from '../../types/events';
-import {isElementTarget, isModifierPressed} from '../../utils/dom-util';
+import {
+ ComboKey,
+ eventMatchesShortcut,
+ isElementTarget,
+ Key,
+ Modifier,
+ Binding,
+ shouldSuppress,
+} from '../../utils/dom-util';
import {ReportingService} from '../gr-reporting/gr-reporting';
export type SectionView = Array<{binding: string[][]; text: string}>;
+export interface ShortcutListener {
+ shortcut: Shortcut;
+ listener: (e: KeyboardEvent) => void;
+}
+
+export function listen(
+ shortcut: Shortcut,
+ listener: (e: KeyboardEvent) => void
+): ShortcutListener {
+ return {shortcut, listener};
+}
+
/**
* The interface for listener for shortcut events.
*/
-export type ShortcutListener = (
+export type ShortcutViewListener = (
viewMap?: Map<ShortcutSection, SectionView>
) => void;
-export enum ComboKey {
- G = 'g',
- V = 'v',
-}
-
function isComboKey(key: string): key is ComboKey {
return Object.values(ComboKey).includes(key as ComboKey);
}
@@ -55,12 +68,18 @@
* show a shortcut help dialog that only shows the shortcuts that are
* currently relevant.
*/
- private readonly activeHosts = new Map<unknown, Map<string, string>>();
+ private readonly activeShortcuts = new Map<HTMLElement, Shortcut[]>();
+
+ /**
+ * Keeps track of cleanup callbacks (which remove keyboard listeners) that
+ * have to be invoked when a component unregisters itself.
+ */
+ private readonly cleanupsPerHost = new Map<HTMLElement, (() => void)[]>();
/** Static map built in the constructor by iterating over the config. */
- private readonly bindings = new Map<Shortcut, string[]>();
+ private readonly bindings = new Map<Shortcut, Binding[]>();
- private readonly listeners = new Set<ShortcutListener>();
+ private readonly listeners = new Set<ShortcutViewListener>();
/**
* Stores the timestamp of the last combo key being pressed.
@@ -89,7 +108,7 @@
}
public _testOnly_isEmpty() {
- return this.activeHosts.size === 0 && this.listeners.size === 0;
+ return this.activeShortcuts.size === 0 && this.listeners.size === 0;
}
isInComboKeyMode() {
@@ -107,41 +126,37 @@
);
}
- modifierPressed(e: IronKeyboardEvent) {
- return isModifierPressed(e) || this.isInComboKeyMode();
+ /**
+ * TODO(brohlfs): Reconcile with the addShortcut() function in dom-util.
+ * Most likely we will just keep this one here, but that is something for a
+ * follow-up change.
+ */
+ addShortcut(
+ element: HTMLElement,
+ shortcut: Binding,
+ listener: (e: KeyboardEvent) => void
+ ) {
+ const wrappedListener = (e: KeyboardEvent) => {
+ if (e.repeat) return;
+ if (!eventMatchesShortcut(e, shortcut)) return;
+ if (shortcut.combo) {
+ if (!this.isInSpecificComboKeyMode(shortcut.combo)) return;
+ } else {
+ if (this.isInComboKeyMode()) return;
+ }
+ if (this.shouldSuppress(e)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ listener(e);
+ };
+ element.addEventListener('keydown', wrappedListener);
+ return () => element.removeEventListener('keydown', wrappedListener);
}
- shouldSuppress(event: IronKeyboardEvent | KeyboardEvent) {
+ shouldSuppress(e: KeyboardEvent) {
if (this.shortcutsDisabled) return true;
- const e = isIronKeyboardEvent(event) ? event.detail.keyboardEvent : event;
+ if (shouldSuppress(e)) return true;
- // Note that when you listen on document, then `e.currentTarget` will be the
- // document and `e.target` will be `<gr-app>` due to shadow dom, but by
- // using the composedPath() you can actually find the true origin of the
- // event.
- const rootTarget = e.composedPath()[0];
- if (!isElementTarget(rootTarget)) return false;
- const tagName = rootTarget.tagName;
- const type = rootTarget.getAttribute('type');
-
- if (
- // Suppress shortcuts on <input> and <textarea>, but not on
- // checkboxes, because we want to enable workflows like 'click
- // mark-reviewed and then press ] to go to the next file'.
- (tagName === 'INPUT' && type !== 'checkbox') ||
- tagName === 'TEXTAREA' ||
- // 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'))
- ) {
- return true;
- }
- const path: EventTarget[] = e.composedPath() ?? [];
- for (const el of path) {
- if (!isElementTarget(el)) continue;
- if (el.tagName === 'GR-OVERLAY') return true;
- }
// eg: {key: "k:keydown", ..., from: "gr-diff-view"}
let key = `${e.key}:${e.type}`;
if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -168,23 +183,37 @@
return this.bindings.get(shortcut);
}
- attachHost(host: unknown, shortcuts: Map<string, string>) {
- this.activeHosts.set(host, shortcuts);
- this.notifyListeners();
+ attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) {
+ this.activeShortcuts.set(
+ host,
+ shortcuts.map(s => s.shortcut)
+ );
+ const cleanups: (() => void)[] = [];
+ for (const s of shortcuts) {
+ const bindings = this.getBindingsForShortcut(s.shortcut);
+ for (const binding of bindings ?? []) {
+ if (binding.docOnly) continue;
+ cleanups.push(this.addShortcut(document.body, binding, s.listener));
+ }
+ }
+ this.cleanupsPerHost.set(host, cleanups);
+ this.notifyViewListeners();
}
- detachHost(host: unknown) {
- if (!this.activeHosts.delete(host)) return false;
- this.notifyListeners();
+ detachHost(host: HTMLElement) {
+ this.activeShortcuts.delete(host);
+ const cleanups = this.cleanupsPerHost.get(host);
+ for (const cleanup of cleanups ?? []) cleanup();
+ this.notifyViewListeners();
return true;
}
- addListener(listener: ShortcutListener) {
+ addListener(listener: ShortcutViewListener) {
this.listeners.add(listener);
listener(this.directoryView());
}
- removeListener(listener: ShortcutListener) {
+ removeListener(listener: ShortcutViewListener) {
return this.listeners.delete(listener);
}
@@ -199,15 +228,17 @@
const bindings = this.bindings.get(shortcutName);
if (!bindings) return '';
return bindings
- .map(binding => this.describeBinding(binding).join('+'))
+ .map(binding => describeBinding(binding).join('+'))
.join(',');
}
activeShortcutsBySection() {
- const activeShortcuts = new Set<string>();
- this.activeHosts.forEach(shortcuts => {
- shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
- });
+ const activeShortcuts = new Set<Shortcut>();
+ for (const shortcuts of this.activeShortcuts.values()) {
+ for (const shortcut of shortcuts) {
+ activeShortcuts.add(shortcut);
+ }
+ }
const activeShortcutsBySection = new Map<
ShortcutSection,
@@ -219,8 +250,6 @@
if (!activeShortcutsBySection.has(section)) {
activeShortcutsBySection.set(section, []);
}
- // From previous condition, the `get(section)`
- // should always return a valid result
activeShortcutsBySection.get(section)!.push(shortcutHelp);
}
});
@@ -282,63 +311,53 @@
describeBindings(shortcut: Shortcut): string[][] | null {
const bindings = this.bindings.get(shortcut);
- if (!bindings) {
- return null;
- }
- if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
- return bindings
- .slice(1)
- .map(binding => this._describeKey(binding))
- .map(binding => ['g'].concat(binding));
- }
- if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
- return bindings
- .slice(1)
- .map(binding => this._describeKey(binding))
- .map(binding => ['v'].concat(binding));
- }
-
+ if (!bindings) return null;
return bindings
- .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
- .map(binding => this.describeBinding(binding));
+ .filter(binding => !binding.docOnly)
+ .map(binding => describeBinding(binding));
}
- _describeKey(key: string) {
- switch (key) {
- case 'shift':
- return 'Shift';
- case 'meta':
- return 'Meta';
- case 'ctrl':
- return 'Ctrl';
- case 'enter':
- return 'Enter';
- case 'up':
- return '\u2191'; // ↑
- case 'down':
- return '\u2193'; // ↓
- case 'left':
- return '\u2190'; // ←
- case 'right':
- return '\u2192'; // →
- default:
- return key;
- }
- }
-
- describeBinding(binding: string) {
- // single key bindings
- if (binding.length === 1) {
- return [binding];
- }
- return binding
- .split(':')[0]
- .split('+')
- .map(part => this._describeKey(part));
- }
-
- notifyListeners() {
+ notifyViewListeners() {
const view = this.directoryView();
this.listeners.forEach(listener => listener(view));
}
}
+
+function describeKey(key: string | Key) {
+ switch (key) {
+ case Key.UP:
+ return '\u2191'; // ↑
+ case Key.DOWN:
+ return '\u2193'; // ↓
+ case Key.LEFT:
+ return '\u2190'; // ←
+ case Key.RIGHT:
+ return '\u2192'; // →
+ default:
+ return key;
+ }
+}
+
+export function describeBinding(binding: Binding): string[] {
+ const description: string[] = [];
+ if (binding.combo === ComboKey.G) {
+ description.push('g');
+ }
+ if (binding.combo === ComboKey.V) {
+ description.push('v');
+ }
+ if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
+ description.push('Shift');
+ }
+ if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
+ description.push('Alt');
+ }
+ if (binding.modifiers?.includes(Modifier.CTRL_KEY)) {
+ description.push('Ctrl');
+ }
+ if (binding.modifiers?.includes(Modifier.META_KEY)) {
+ description.push('Meta/Cmd');
+ }
+ description.push(describeKey(binding.key));
+ return description;
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index d8aa11e..05c4f53 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -16,12 +16,14 @@
*/
import '../../test/common-test-setup-karma';
import {
- ShortcutsService,
COMBO_TIMEOUT_MS,
+ describeBinding,
+ 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,
@@ -96,13 +98,12 @@
});
test('getShortcut', () => {
- const NEXT_FILE = Shortcut.NEXT_FILE;
- assert.equal(service.getShortcut(NEXT_FILE), ']');
- });
-
- test('getShortcut with modifiers', () => {
- const NEXT_FILE = Shortcut.TOGGLE_LEFT_PANE;
- assert.equal(service.getShortcut(NEXT_FILE), 'Shift+a');
+ assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
+ assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
+ assert.equal(
+ service.getShortcut(Shortcut.SEND_REPLY),
+ 'Ctrl+Enter,Meta/Cmd+Enter'
+ );
});
suite('binding descriptions', () => {
@@ -113,14 +114,18 @@
}
test('single combo description', () => {
- assert.deepEqual(service.describeBinding('a'), ['a']);
- assert.deepEqual(service.describeBinding('a:keyup'), ['a']);
- assert.deepEqual(service.describeBinding('ctrl+a'), ['Ctrl', 'a']);
- assert.deepEqual(service.describeBinding('ctrl+shift+up:keyup'), [
- 'Ctrl',
- 'Shift',
- '↑',
- ]);
+ assert.deepEqual(describeBinding({key: 'a'}), ['a']);
+ assert.deepEqual(
+ describeBinding({key: 'a', modifiers: [Modifier.CTRL_KEY]}),
+ ['Ctrl', 'a']
+ );
+ assert.deepEqual(
+ describeBinding({
+ key: Key.UP,
+ modifiers: [Modifier.CTRL_KEY, Modifier.SHIFT_KEY],
+ }),
+ ['Shift', 'Ctrl', '↑']
+ );
});
test('combo set description', () => {
@@ -130,9 +135,9 @@
);
assert.deepEqual(service.describeBindings(Shortcut.SAVE_COMMENT), [
['Ctrl', 'Enter'],
- ['Meta', 'Enter'],
+ ['Meta/Cmd', 'Enter'],
['Ctrl', 's'],
- ['Meta', 's'],
+ ['Meta/Cmd', 's'],
]);
assert.deepEqual(service.describeBindings(Shortcut.PREV_FILE), [['[']]);
});
@@ -187,67 +192,68 @@
test('active shortcuts by section', () => {
assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {});
- service.attachHost({}, new Map([[Shortcut.NEXT_FILE, 'null']]));
+ service.attachHost(document.createElement('div'), [
+ {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
+ ]);
assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
[ShortcutSection.NAVIGATION]: [
{
shortcut: Shortcut.NEXT_FILE,
text: 'Go to next file',
- bindings: [']'],
+ bindings: [{key: ']'}],
},
],
});
- service.attachHost({}, new Map([[Shortcut.NEXT_LINE, 'null']]));
+ service.attachHost(document.createElement('div'), [
+ {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
+ ]);
assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
[ShortcutSection.DIFFS]: [
{
shortcut: Shortcut.NEXT_LINE,
text: 'Go to next line',
- bindings: ['j', 'down'],
+ bindings: [{key: 'j'}, {key: 'ArrowDown'}],
},
],
[ShortcutSection.NAVIGATION]: [
{
shortcut: Shortcut.NEXT_FILE,
text: 'Go to next file',
- bindings: [']'],
+ bindings: [{key: ']'}],
},
],
});
- service.attachHost(
- {},
- new Map([
- [Shortcut.SEARCH, 'null'],
- [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
- ])
- );
+ service.attachHost(document.createElement('div'), [
+ {shortcut: Shortcut.SEARCH, listener: _ => {}},
+ {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
+ ]);
assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
[ShortcutSection.DIFFS]: [
{
shortcut: Shortcut.NEXT_LINE,
text: 'Go to next line',
- bindings: ['j', 'down'],
+ bindings: [{key: 'j'}, {key: 'ArrowDown'}],
},
],
[ShortcutSection.EVERYWHERE]: [
{
shortcut: Shortcut.SEARCH,
text: 'Search',
- bindings: ['/'],
+ bindings: [{key: '/'}],
},
{
shortcut: Shortcut.GO_TO_OPENED_CHANGES,
text: 'Go to Opened Changes',
- bindings: ['GO_KEY', 'o'],
+ bindings: [{key: 'o', combo: 'g'}],
},
],
[ShortcutSection.NAVIGATION]: [
{
shortcut: Shortcut.NEXT_FILE,
text: 'Go to next file',
- bindings: [']'],
+ bindings: [{key: ']'}],
},
],
});
@@ -256,33 +262,31 @@
test('directory view', () => {
assert.deepEqual(mapToObject(service.directoryView()), {});
- service.attachHost(
- {},
- new Map([
- [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
- [Shortcut.NEXT_FILE, 'null'],
- [Shortcut.NEXT_LINE, 'null'],
- [Shortcut.SAVE_COMMENT, 'null'],
- [Shortcut.SEARCH, 'null'],
- ])
- );
+ service.attachHost(document.createElement('div'), [
+ {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
+ {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
+ {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
+ {shortcut: Shortcut.SAVE_COMMENT, listener: _ => {}},
+ {shortcut: Shortcut.SEARCH, listener: _ => {}},
+ ]);
assert.deepEqual(mapToObject(service.directoryView()), {
[ShortcutSection.DIFFS]: [
{binding: [['j'], ['↓']], text: 'Go to next line'},
{
- binding: [
- ['Ctrl', 'Enter'],
- ['Meta', 'Enter'],
- ],
+ binding: [['Ctrl', 'Enter']],
text: 'Save comment',
},
{
binding: [
+ ['Meta/Cmd', 'Enter'],
['Ctrl', 's'],
- ['Meta', 's'],
],
text: 'Save comment',
},
+ {
+ binding: [['Meta/Cmd', 's']],
+ text: 'Save comment',
+ },
],
[ShortcutSection.EVERYWHERE]: [
{binding: [['/']], text: 'Search'},
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 72ce3e1..6d31f6c 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -17,7 +17,10 @@
import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
import {BehaviorSubject, Observable} from 'rxjs';
import {map, distinctUntilChanged} from 'rxjs/operators';
-import {createDefaultPreferences} from '../../constants/constants';
+import {
+ createDefaultPreferences,
+ DiffViewMode,
+} from '../../constants/constants';
interface UserState {
/**
@@ -31,7 +34,20 @@
preferences: createDefaultPreferences(),
};
-const privateState$ = new BehaviorSubject(initialState);
+// Mutable for testing
+let privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+ privateState$ = new BehaviorSubject(initialState);
+}
+
+export function _testOnly_setState(state: 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$;
@@ -56,6 +72,11 @@
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()
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
index 125d20c..0588a4a 100644
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ b/polygerrit-ui/app/services/user/user-service.ts
@@ -14,11 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {
- AccountDetailInfo,
- PreferencesInfo,
- PreferencesInput,
-} from '../../types/common';
+import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
import {from, of} from 'rxjs';
import {account$, updateAccount, updatePreferences} from './user-model';
import {switchMap} from 'rxjs/operators';
@@ -44,7 +40,7 @@
});
}
- updatePreferences(prefs: PreferencesInput) {
+ updatePreferences(prefs: Partial<PreferencesInfo>) {
this.restApiService
.savePreferences(prefs)
.then((newPrefs: PreferencesInfo | undefined) => {
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
index 3463d3b..39c79d1 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.ts
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -118,7 +118,7 @@
}
class TestFixture {
- constructor(private readonly fixtureId: string) {}
+ constructor(readonly fixtureId: string) {}
/**
* Create an instance of a fixture's template.
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 39c30ad..49ecac8 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -25,6 +25,7 @@
import {ReportingService} from '../services/gr-reporting/gr-reporting';
import {CommentsService} from '../services/comments/comments-service';
import {UserService} from '../services/user/user-service';
+import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
export {query, queryAll, queryAndAssert} from '../utils/common-util';
export interface MockPromise extends Promise<unknown> {
@@ -116,6 +117,10 @@
return sinon.stub(appContext.userService, method);
}
+export function stubShortcuts<K extends keyof ShortcutsService>(method: K) {
+ return sinon.stub(appContext.shortcutsService, method);
+}
+
export function stubStorage<K extends keyof StorageService>(method: K) {
return sinon.stub(appContext.storageService, method);
}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 1617aa3..3ac7c7b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1147,8 +1147,6 @@
work_in_progress_by_default?: boolean;
// The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
email_format?: EmailFormat;
- // The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
- default_diff_view?: DiffViewMode;
}
/**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index c78f61a..b6376c4 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -241,30 +241,3 @@
title: string;
}
export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
-
-/**
- * Keyboard events emitted from elements using IronA11yKeysBehavior: That means
- * that the element returns a list of handlers from either `keyBindings()` or
- * from `keyboardShortcuts()`. This event should not be used in Lit elements
- * and will be obsolete once the Lit migration is completed.
- */
-export interface IronKeyboardEvent extends CustomEvent {
- detail: IronKeyboardEventDetail;
-}
-
-export interface IronKeyboardEventDetail {
- keyboardEvent: KeyboardEvent;
- key: string;
- combo?: string;
-}
-
-export function isIronKeyboardEvent(
- e: IronKeyboardEvent | Event | CustomEvent
-): e is IronKeyboardEvent {
- const ike = e as IronKeyboardEvent;
- return !!ike?.detail?.keyboardEvent;
-}
-
-export interface IronKeyboardEventListener {
- (evt: IronKeyboardEvent): void;
-}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 2d4d412..b90b12f 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -189,7 +189,6 @@
showDownloadDialog: boolean;
diffMode: DiffViewMode | null;
numFilesShown: number | null;
- diffViewMode?: boolean;
}
export interface ChangeListViewState {
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index e3e342e..e2fa8fe 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -16,7 +16,6 @@
*/
import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {check} from './common-util';
-import {IronKeyboardEvent} from '../types/events';
/**
* Event emitted from polymer elements.
@@ -326,30 +325,95 @@
SHIFT_KEY,
}
-export interface Shortcut {
- key: string;
+export enum ComboKey {
+ G = 'g',
+ V = 'v',
+}
+
+export interface Binding {
+ key: string | Key;
+ /** Defaults to false. */
+ docOnly?: boolean;
+ /** Defaults to not being a combo shortcut. */
+ combo?: ComboKey;
+ /** Defaults to no modifiers. */
modifiers?: Modifier[];
}
-export function addShortcut(
- shortcut: Shortcut,
+const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/);
+
+/**
+ * For "normal" keys we do not check that the SHIFT modifier is pressed or not,
+ * because that depends on the keyboard layout. Just checking the key string is
+ * sufficient.
+ *
+ * But for some special keys it is important whether SHIFT is pressed at the
+ * same time, for example we want to distinguish Enter from Shift+Enter.
+ */
+function shiftMustMatch(key: string | Key) {
+ return Object.values(Key).includes(key as Key);
+}
+
+/**
+ * For a-zA-Z0-9 and for Enter, Tab, etc. we want to check the ALT modifier.
+ *
+ * But for special chars like []/? we don't care whether the user is pressing
+ * the ALT modifier to produce the special char. For example on a German
+ * keyboard layout you have to press ALT to produce a [.
+ */
+function altMustMatch(key: string | Key) {
+ return ALPHA_NUM.test(key) || Object.values(Key).includes(key as Key);
+}
+
+export function eventMatchesShortcut(
+ e: KeyboardEvent,
+ shortcut: Binding
+): boolean {
+ if (e.key !== shortcut.key) return false;
+ const modifiers = shortcut.modifiers ?? [];
+ if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return false;
+ if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return false;
+ if (
+ altMustMatch(e.key) &&
+ e.altKey !== modifiers.includes(Modifier.ALT_KEY)
+ ) {
+ return false;
+ }
+ if (
+ shiftMustMatch(e.key) &&
+ e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY)
+ ) {
+ return false;
+ }
+ return true;
+}
+
+export function addGlobalShortcut(
+ shortcut: Binding,
listener: (e: KeyboardEvent) => void
) {
+ return addShortcut(document.body, shortcut, listener);
+}
+
+export function addShortcut(
+ element: HTMLElement,
+ shortcut: Binding,
+ listener: (e: KeyboardEvent) => void,
+ options: {
+ shouldSuppress: boolean;
+ } = {
+ shouldSuppress: false,
+ }
+) {
const wrappedListener = (e: KeyboardEvent) => {
- if (e.key !== shortcut.key) return;
- const modifiers = shortcut.modifiers ?? [];
- if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return;
- if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return;
- // TODO(brohlfs): Refine the matching of modifiers. For "normal" keys we
- // don't want to check ALT and SHIFT, because we don't know what the
- // keyboard layout looks like. The user may have to use ALT and/or SHIFT for
- // certain keys. Comparing the `key` string is sufficient in that case.
- // if (e.altKey !== modifiers.includes(Modifier.ALT_KEY)) return;
- // if (e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY)) return;
- listener(e);
+ if (e.repeat) return;
+ if (options.shouldSuppress && shouldSuppress(e)) return;
+ if (eventMatchesShortcut(e, shortcut)) {
+ listener(e);
+ }
};
- document.addEventListener('keydown', wrappedListener);
- return () => document.removeEventListener('keydown', wrappedListener);
+ element.addEventListener('keydown', wrappedListener);
+ return () => element.removeEventListener('keydown', wrappedListener);
}
export function modifierPressed(e: KeyboardEvent) {
@@ -360,10 +424,42 @@
return e.shiftKey;
}
-export function isModifierPressed(e: IronKeyboardEvent) {
- return modifierPressed(e.detail.keyboardEvent);
-}
+/**
+ * When you listen on keyboard events, then within Gerrit's web app you may want
+ * to avoid firing in certain common scenarios such as key strokes from <input>
+ * elements. But this can also be undesirable, for example Ctrl-Enter from
+ * <input> should trigger a save event.
+ *
+ * The shortcuts-service has a stateful method `shouldSuppress()` with
+ * reporting functionality, which delegates to here.
+ */
+export function shouldSuppress(e: KeyboardEvent): boolean {
+ // Note that when you listen on document, then `e.currentTarget` will be the
+ // document and `e.target` will be `<gr-app>` due to shadow dom, but by
+ // using the composedPath() you can actually find the true origin of the
+ // event.
+ const rootTarget = e.composedPath()[0];
+ if (!isElementTarget(rootTarget)) return false;
+ const tagName = rootTarget.tagName;
+ const type = rootTarget.getAttribute('type');
-export function isShiftPressed(e: IronKeyboardEvent) {
- return shiftPressed(e.detail.keyboardEvent);
+ if (
+ // Suppress shortcuts on <input> and <textarea>, but not on
+ // checkboxes, because we want to enable workflows like 'click
+ // mark-reviewed and then press ] to go to the next file'.
+ (tagName === 'INPUT' && type !== 'checkbox') ||
+ tagName === 'TEXTAREA' ||
+ // 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'))
+ ) {
+ return true;
+ }
+ const path: EventTarget[] = e.composedPath() ?? [];
+ for (const el of path) {
+ if (!isElementTarget(el)) continue;
+ if (el.tagName === 'GR-OVERLAY') return true;
+ }
+ return false;
}
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
deleted file mode 100644
index bcb4505..0000000
--- a/polygerrit-ui/app/utils/dom-util_test.js
+++ /dev/null
@@ -1,155 +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 '../test/common-test-setup-karma.js';
-import {strToClassName, getComputedStyleValue, querySelector, querySelectorAll, descendedFromClass, getEventPath} from './dom-util.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-class TestEle extends PolymerElement {
- static get is() {
- return 'dom-util-test-element';
- }
-
- static get template() {
- return html`
- <div>
- <div class="a">
- <div class="b">
- <div class="c"></div>
- </div>
- <span class="ss"></span>
- </div>
- <span class="ss"></span>
- </div>
- `;
- }
-}
-
-customElements.define(TestEle.is, TestEle);
-
-const basicFixture = fixtureFromTemplate(html`
- <div id="test" class="a b c">
- <a class="testBtn" style="color:red;"></a>
- <dom-util-test-element></dom-util-test-element>
- <span class="ss"></span>
- </div>
-`);
-
-suite('dom-util tests', () => {
- suite('getEventPath', () => {
- test('empty event', () => {
- assert.equal(getEventPath(), '');
- assert.equal(getEventPath(null), '');
- assert.equal(getEventPath(undefined), '');
- assert.equal(getEventPath({composedPath: () => []}), '');
- });
-
- test('event with fake path', () => {
- assert.equal(getEventPath({composedPath: () => []}), '');
- const dd = document.createElement('dd');
- assert.equal(getEventPath({composedPath: () => [dd]}), 'dd');
- });
-
- test('event with fake complicated path', () => {
- const dd = document.createElement('dd');
- dd.setAttribute('id', 'test');
- dd.className = 'a b';
- const divNode = document.createElement('DIV');
- divNode.id = 'test2';
- divNode.className = 'a b c';
- assert.equal(getEventPath(
- {composedPath: () => [dd, divNode]}),
- 'div#test2.a.b.c>dd#test.a.b'
- );
- });
-
- test('event with fake target', () => {
- const fakeTargetParent1 = document.createElement('dd');
- fakeTargetParent1.setAttribute('id', 'test');
- fakeTargetParent1.className = 'a b';
- const fakeTargetParent2 = document.createElement('DIV');
- fakeTargetParent2.id = 'test2';
- fakeTargetParent2.className = 'a b c';
- fakeTargetParent2.appendChild(fakeTargetParent1);
- const fakeTarget = document.createElement('SPAN');
- fakeTargetParent1.appendChild(fakeTarget);
- assert.equal(
- getEventPath({composedPath: () => {}, target: fakeTarget}),
- 'div#test2.a.b.c>dd#test.a.b>span'
- );
- });
-
- test('event with real click', () => {
- const element = basicFixture.instantiate();
- const aLink = element.querySelector('a');
- let path;
- aLink.onclick = e => path = getEventPath(e);
- MockInteractions.click(aLink);
- assert.equal(
- path,
- `html>body>test-fixture#${basicFixture.fixtureId}>` +
- 'div#test.a.b.c>a.testBtn'
- );
- });
- });
-
- suite('querySelector and querySelectorAll', () => {
- test('query cross shadow dom', () => {
- const element = basicFixture.instantiate();
- const theFirstEl = querySelector(element, '.ss');
- const allEls = querySelectorAll(element, '.ss');
- assert.equal(allEls.length, 3);
- assert.equal(theFirstEl, allEls[0]);
- });
- });
-
- suite('getComputedStyleValue', () => {
- test('color style', () => {
- const element = basicFixture.instantiate();
- const testBtn = querySelector(element, '.testBtn');
- assert.equal(
- getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)'
- );
- });
- });
-
- suite('descendedFromClass', () => {
- test('basic tests', () => {
- const element = basicFixture.instantiate();
- const testEl = querySelector(element, 'dom-util-test-element');
- // .c is a child of .a and not vice versa.
- assert.isTrue(descendedFromClass(querySelector(testEl, '.c'), 'a'));
- assert.isFalse(descendedFromClass(querySelector(testEl, '.a'), 'c'));
-
- // Stops at stop element.
- assert.isFalse(descendedFromClass(querySelector(testEl, '.c'), 'a',
- querySelector(testEl, '.b')));
- });
- });
-
- suite('strToClassName', () => {
- test('basic tests', () => {
- assert.equal(strToClassName(''), 'generated_');
- assert.equal(strToClassName('11'), 'generated_11');
- assert.equal(strToClassName('0.123'), 'generated_0_123');
- assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
- assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
- assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
- assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
- });
- });
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
new file mode 100644
index 0000000..9dd5be2
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -0,0 +1,335 @@
+/**
+ * @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 {
+ descendedFromClass,
+ eventMatchesShortcut,
+ getComputedStyleValue,
+ getEventPath,
+ Modifier,
+ querySelectorAll,
+ shouldSuppress,
+ strToClassName,
+} from './dom-util';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAndAssert} from '../test/test-utils';
+
+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;
+}
+
+class TestEle extends PolymerElement {
+ static get is() {
+ return 'dom-util-test-element';
+ }
+
+ static get template() {
+ return html`
+ <div>
+ <div class="a">
+ <div class="b">
+ <div class="c"></div>
+ </div>
+ <span class="ss"></span>
+ </div>
+ <span class="ss"></span>
+ </div>
+ `;
+ }
+}
+
+customElements.define(TestEle.is, TestEle);
+
+const basicFixture = fixtureFromTemplate(html`
+ <div id="test" class="a b c">
+ <a class="testBtn" style="color:red;"></a>
+ <dom-util-test-element></dom-util-test-element>
+ <span class="ss"></span>
+ </div>
+`);
+
+suite('dom-util tests', () => {
+ suite('getEventPath', () => {
+ test('empty event', () => {
+ assert.equal(getEventPath(), '');
+ assert.equal(getEventPath(undefined), '');
+ assert.equal(getEventPath(new MouseEvent('click')), '');
+ });
+
+ test('event with fake path', () => {
+ assert.equal(getEventPath(new MouseEvent('click')), '');
+ const dd = document.createElement('dd');
+ assert.equal(
+ getEventPath({...new MouseEvent('click'), composedPath: () => [dd]}),
+ 'dd'
+ );
+ });
+
+ test('event with fake complicated path', () => {
+ const dd = document.createElement('dd');
+ dd.setAttribute('id', 'test');
+ dd.className = 'a b';
+ const divNode = document.createElement('DIV');
+ divNode.id = 'test2';
+ divNode.className = 'a b c';
+ assert.equal(
+ getEventPath({
+ ...new MouseEvent('click'),
+ composedPath: () => [dd, divNode],
+ }),
+ 'div#test2.a.b.c>dd#test.a.b'
+ );
+ });
+
+ test('event with fake target', () => {
+ const fakeTargetParent1 = document.createElement('dd');
+ fakeTargetParent1.setAttribute('id', 'test');
+ fakeTargetParent1.className = 'a b';
+ const fakeTargetParent2 = document.createElement('DIV');
+ fakeTargetParent2.id = 'test2';
+ fakeTargetParent2.className = 'a b c';
+ fakeTargetParent2.appendChild(fakeTargetParent1);
+ const fakeTarget = document.createElement('SPAN');
+ fakeTargetParent1.appendChild(fakeTarget);
+ assert.equal(
+ getEventPath({
+ ...new MouseEvent('click'),
+ composedPath: () => [],
+ target: fakeTarget,
+ }),
+ 'div#test2.a.b.c>dd#test.a.b>span'
+ );
+ });
+
+ test('event with real click', () => {
+ const element = basicFixture.instantiate() as HTMLElement;
+ const aLink = queryAndAssert(element, 'a');
+ let path;
+ aLink.addEventListener('click', (e: Event) => {
+ path = getEventPath(e as MouseEvent);
+ });
+ MockInteractions.click(aLink);
+ assert.equal(
+ path,
+ `html>body>test-fixture#${basicFixture.fixtureId}>` +
+ 'div#test.a.b.c>a.testBtn'
+ );
+ });
+ });
+
+ suite('querySelector and querySelectorAll', () => {
+ test('query cross shadow dom', () => {
+ const element = basicFixture.instantiate() as HTMLElement;
+ const theFirstEl = queryAndAssert(element, '.ss');
+ const allEls = querySelectorAll(element, '.ss');
+ assert.equal(allEls.length, 3);
+ assert.equal(theFirstEl, allEls[0]);
+ });
+ });
+
+ suite('getComputedStyleValue', () => {
+ test('color style', () => {
+ const element = basicFixture.instantiate() as HTMLElement;
+ const testBtn = queryAndAssert(element, '.testBtn');
+ assert.equal(getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)');
+ });
+ });
+
+ suite('descendedFromClass', () => {
+ test('basic tests', () => {
+ const element = basicFixture.instantiate() as HTMLElement;
+ const testEl = queryAndAssert(element, 'dom-util-test-element');
+ // .c is a child of .a and not vice versa.
+ assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'a'));
+ assert.isFalse(descendedFromClass(queryAndAssert(testEl, '.a'), 'c'));
+
+ // Stops at stop element.
+ assert.isFalse(
+ descendedFromClass(
+ queryAndAssert(testEl, '.c'),
+ 'a',
+ queryAndAssert(testEl, '.b')
+ )
+ );
+ });
+ });
+
+ suite('strToClassName', () => {
+ test('basic tests', () => {
+ assert.equal(strToClassName(''), 'generated_');
+ assert.equal(strToClassName('11'), 'generated_11');
+ assert.equal(strToClassName('0.123'), 'generated_0_123');
+ assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
+ assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
+ assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
+ assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
+ });
+ });
+
+ suite('eventMatchesShortcut', () => {
+ test('basic tests', () => {
+ const a = new KeyboardEvent('keydown', {key: 'a'});
+ const b = new KeyboardEvent('keydown', {key: 'B'});
+ assert.isTrue(eventMatchesShortcut(a, {key: 'a'}));
+ assert.isFalse(eventMatchesShortcut(a, {key: 'B'}));
+ assert.isFalse(eventMatchesShortcut(b, {key: 'a'}));
+ assert.isTrue(eventMatchesShortcut(b, {key: 'B'}));
+ });
+
+ test('check modifiers for a', () => {
+ const e = new KeyboardEvent('keydown', {key: 'a'});
+ const s = {key: 'a'};
+ assert.isTrue(eventMatchesShortcut(e, s));
+
+ const eAlt = new KeyboardEvent('keydown', {key: 'a', altKey: true});
+ const sAlt = {key: 'a', modifiers: [Modifier.ALT_KEY]};
+ assert.isFalse(eventMatchesShortcut(eAlt, s));
+ assert.isFalse(eventMatchesShortcut(e, sAlt));
+ const eCtrl = new KeyboardEvent('keydown', {key: 'a', ctrlKey: true});
+ const sCtrl = {key: 'a', modifiers: [Modifier.CTRL_KEY]};
+ assert.isFalse(eventMatchesShortcut(eCtrl, s));
+ assert.isFalse(eventMatchesShortcut(e, sCtrl));
+ const eMeta = new KeyboardEvent('keydown', {key: 'a', metaKey: true});
+ const sMeta = {key: 'a', modifiers: [Modifier.META_KEY]};
+ assert.isFalse(eventMatchesShortcut(eMeta, s));
+ assert.isFalse(eventMatchesShortcut(e, sMeta));
+
+ // Do NOT check SHIFT for alphanum keys.
+ const eShift = new KeyboardEvent('keydown', {key: 'a', shiftKey: true});
+ const sShift = {key: 'a', modifiers: [Modifier.SHIFT_KEY]};
+ assert.isTrue(eventMatchesShortcut(eShift, s));
+ assert.isTrue(eventMatchesShortcut(e, sShift));
+ });
+
+ test('check modifiers for Enter', () => {
+ const e = new KeyboardEvent('keydown', {key: 'Enter'});
+ const s = {key: 'Enter'};
+ assert.isTrue(eventMatchesShortcut(e, s));
+
+ const eAlt = new KeyboardEvent('keydown', {key: 'Enter', altKey: true});
+ const sAlt = {key: 'Enter', modifiers: [Modifier.ALT_KEY]};
+ assert.isFalse(eventMatchesShortcut(eAlt, s));
+ assert.isFalse(eventMatchesShortcut(e, sAlt));
+ const eCtrl = new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: true});
+ const sCtrl = {key: 'Enter', modifiers: [Modifier.CTRL_KEY]};
+ assert.isFalse(eventMatchesShortcut(eCtrl, s));
+ assert.isFalse(eventMatchesShortcut(e, sCtrl));
+ const eMeta = new KeyboardEvent('keydown', {key: 'Enter', metaKey: true});
+ const sMeta = {key: 'Enter', modifiers: [Modifier.META_KEY]};
+ assert.isFalse(eventMatchesShortcut(eMeta, s));
+ assert.isFalse(eventMatchesShortcut(e, sMeta));
+ const eShift = new KeyboardEvent('keydown', {
+ key: 'Enter',
+ shiftKey: true,
+ });
+ const sShift = {key: 'Enter', modifiers: [Modifier.SHIFT_KEY]};
+ assert.isFalse(eventMatchesShortcut(eShift, s));
+ assert.isFalse(eventMatchesShortcut(e, sShift));
+ });
+
+ test('check modifiers for [', () => {
+ const e = new KeyboardEvent('keydown', {key: '['});
+ const s = {key: '['};
+ assert.isTrue(eventMatchesShortcut(e, s));
+
+ const eCtrl = new KeyboardEvent('keydown', {key: '[', ctrlKey: true});
+ const sCtrl = {key: '[', modifiers: [Modifier.CTRL_KEY]};
+ assert.isFalse(eventMatchesShortcut(eCtrl, s));
+ assert.isFalse(eventMatchesShortcut(e, sCtrl));
+ const eMeta = new KeyboardEvent('keydown', {key: '[', metaKey: true});
+ const sMeta = {key: '[', modifiers: [Modifier.META_KEY]};
+ assert.isFalse(eventMatchesShortcut(eMeta, s));
+ assert.isFalse(eventMatchesShortcut(e, sMeta));
+
+ // Do NOT check SHIFT and ALT for special chars like [.
+ const eAlt = new KeyboardEvent('keydown', {key: '[', altKey: true});
+ const sAlt = {key: '[', modifiers: [Modifier.ALT_KEY]};
+ assert.isTrue(eventMatchesShortcut(eAlt, s));
+ assert.isTrue(eventMatchesShortcut(e, sAlt));
+ const eShift = new KeyboardEvent('keydown', {
+ key: '[',
+ shiftKey: true,
+ });
+ const sShift = {key: '[', modifiers: [Modifier.SHIFT_KEY]};
+ assert.isTrue(eventMatchesShortcut(eShift, s));
+ assert.isTrue(eventMatchesShortcut(e, sShift));
+ });
+ });
+
+ suite('shouldSuppress', () => {
+ test('do not suppress shortcut event from <div>', async () => {
+ await keyEventOn(document.createElement('div'), e => {
+ assert.isFalse(shouldSuppress(e));
+ });
+ });
+
+ test('suppress shortcut event from <input>', async () => {
+ await keyEventOn(document.createElement('input'), e => {
+ assert.isTrue(shouldSuppress(e));
+ });
+ });
+
+ test('suppress shortcut event from <textarea>', async () => {
+ await keyEventOn(document.createElement('textarea'), e => {
+ assert.isTrue(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(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(shouldSuppress(e));
+ });
+ });
+
+ test('suppress "enter" shortcut event from <a>', async () => {
+ await keyEventOn(document.createElement('a'), e => {
+ assert.isFalse(shouldSuppress(e));
+ });
+ await keyEventOn(
+ document.createElement('a'),
+ e => assert.isTrue(shouldSuppress(e)),
+ 13,
+ 'enter'
+ );
+ });
+ });
+});
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 918d2ab..b95fa4d 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -31,7 +31,11 @@
import {assertNever, unique} from './common-util';
// Name of the standard Code-Review label.
-export const CODE_REVIEW = 'Code-Review';
+export enum StandardLabels {
+ CODE_REVIEW = 'Code-Review',
+ CODE_OWNERS = 'Code Owners',
+ PRESUBMIT_VERIFIED = 'Presubmit-Verified',
+}
export enum LabelStatus {
APPROVED = 'APPROVED',
@@ -178,9 +182,13 @@
}
export function labelCompare(labelName1: string, labelName2: string) {
- if (labelName1 === CODE_REVIEW && labelName2 === CODE_REVIEW) return 0;
- if (labelName1 === CODE_REVIEW) return -1;
- if (labelName2 === CODE_REVIEW) return 1;
+ if (
+ labelName1 === StandardLabels.CODE_REVIEW &&
+ labelName2 === StandardLabels.CODE_REVIEW
+ )
+ return 0;
+ if (labelName1 === StandardLabels.CODE_REVIEW) return -1;
+ if (labelName2 === StandardLabels.CODE_REVIEW) return 1;
return labelName1.localeCompare(labelName2);
}
@@ -189,26 +197,34 @@
labels: LabelNameToInfoMap
): LabelInfo | undefined {
for (const label of Object.keys(labels)) {
- if (label === CODE_REVIEW) {
+ if (label === StandardLabels.CODE_REVIEW) {
return labels[label];
}
}
return;
}
-export function extractAssociatedLabels(
- requirement: SubmitRequirementResultInfo
-): string[] {
+function extractLabelsFrom(expression: string) {
const pattern = new RegExp('label[0-9]*:([\\w-]+)', 'g');
const labels = [];
let match;
- while (
- (match = pattern.exec(
- requirement.submittability_expression_result.expression
- )) !== null
- ) {
+ while ((match = pattern.exec(expression)) !== null) {
labels.push(match[1]);
}
+ return labels;
+}
+
+export function extractAssociatedLabels(
+ requirement: SubmitRequirementResultInfo
+): string[] {
+ let labels = extractLabelsFrom(
+ requirement.submittability_expression_result.expression
+ );
+ if (requirement.override_expression_result) {
+ labels = labels.concat(
+ extractLabelsFrom(requirement.override_expression_result.expression)
+ );
+ }
return labels.filter(unique);
}
@@ -219,10 +235,31 @@
case SubmitRequirementStatus.UNSATISFIED:
return 'close';
case SubmitRequirementStatus.OVERRIDDEN:
- return 'warning';
+ return 'overridden';
case SubmitRequirementStatus.NOT_APPLICABLE:
return 'info';
default:
assertNever(status, `Unsupported status: ${status}`);
}
}
+
+// TODO(milutin): This may be temporary for demo purposes
+const PRIORITY_REQUIREMENTS_ORDER: string[] = [
+ StandardLabels.CODE_REVIEW,
+ StandardLabels.CODE_OWNERS,
+ StandardLabels.PRESUBMIT_VERIFIED,
+];
+export function orderSubmitRequirements(
+ requirements: SubmitRequirementResultInfo[]
+) {
+ let priorityRequirementList: SubmitRequirementResultInfo[] = [];
+ for (const label of PRIORITY_REQUIREMENTS_ORDER) {
+ const priorityRequirement = requirements.filter(r => r.name === label);
+ priorityRequirementList =
+ priorityRequirementList.concat(priorityRequirement);
+ }
+ const nonPriorityRequirements = requirements.filter(
+ r => !PRIORITY_REQUIREMENTS_ORDER.includes(r.name)
+ );
+ return priorityRequirementList.concat(nonPriorityRequirements);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index 9360688..2d59294 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -256,5 +256,18 @@
const labels = extractAssociatedLabels(submitRequirement);
assert.deepEqual(labels, ['Verified', 'Code-Review']);
});
+ test('overridden label', () => {
+ const submitRequirement = {
+ ...createSubmitRequirementExpressionInfoWith(
+ 'label:Verified=MAX -label:Verified=MIN'
+ ),
+ override_expression_result: {
+ ...createSubmitRequirementExpressionInfo(),
+ expression: 'label:Build-cop-override',
+ },
+ };
+ const labels = extractAssociatedLabels(submitRequirement);
+ assert.deepEqual(labels, ['Verified', 'Build-cop-override']);
+ });
});
});
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index bfca566..d3b22eb 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -46,7 +46,7 @@
dependencies:
"@polymer/polymer" "^3.0.0"
-"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26", "@polymer/iron-a11y-keys-behavior@^3.0.1":
+"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-keys-behavior/-/iron-a11y-keys-behavior-3.0.1.tgz#2868ea72912d2007ffab4734684a91f5aac49b84"
integrity sha512-lnrjKq3ysbBPT/74l0Fj0U9H9C35Tpw2C/tpJ8a+5g8Y3YJs1WSZYnEl1yOkw6sEyaxOq/1DkzH0+60gGu5/PQ==
diff --git a/tools/BUILD b/tools/BUILD
index 47a2a2e..5d8491a 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -244,7 +244,7 @@
"-Xep:InvalidInlineTag:ERROR",
"-Xep:InvalidJavaTimeConstant:ERROR",
"-Xep:InvalidLink:ERROR",
- # "-Xep:InvalidParam:WARN",
+ "-Xep:InvalidParam:ERROR",
"-Xep:InvalidPatternSyntax:ERROR",
"-Xep:InvalidThrows:ERROR",
"-Xep:InvalidThrowsLink:ERROR",