Merge "Introduce topic bulk action flow"
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 8e5463d..1151f1c 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -204,6 +204,13 @@
* `-Dcom.google.gerrit.scenarios.context_path=/context`
+==== Authentication
+
+The `authenticated` property allows test scenarios to use authenticated HTTP clones. Its default is
+no authentication:
+
+* `-Dcom.google.gerrit.scenarios.authenticated=false`
+
==== Automatic properties
The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index 7946f05..5af4189 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -108,7 +108,7 @@
val property = packageName + "." + term
var value = default
default match {
- case _: String | _: Double =>
+ case _: String | _: Double | _: Boolean =>
val propertyValue = Option(System.getProperty(property))
if (propertyValue.nonEmpty) {
value = propertyValue.get
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index 5d5f5d5..5d8dd6f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -32,6 +32,9 @@
override def replaceOverride(in: String): String = {
var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+ val authenticated = getProperty("authenticated", false).toBoolean
+ val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+ next = replaceKeyWith("context_path", value, next)
super.replaceOverride(next)
}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index e09f2f4..658409c 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -213,6 +213,7 @@
factory(EmailReviewComments.Factory.class);
factory(PatchSetInserter.Factory.class);
factory(AddReviewersOp.Factory.class);
+ factory(PostReviewOp.Factory.class);
factory(RebaseChangeOp.Factory.class);
factory(ReviewerResource.Factory.class);
factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 39bd0d0..8b47e1e 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,23 +15,17 @@
package com.google.gerrit.server.restapi.change;
import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import com.google.auto.value.AutoValue;
-import com.google.common.base.Joiner;
import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
@@ -44,16 +38,11 @@
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.FixSuggestion;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -75,29 +64,20 @@
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.extensions.validators.CommentForValidation;
-import com.google.gerrit.extensions.validators.CommentValidationContext;
-import com.google.gerrit.extensions.validators.CommentValidationFailure;
-import com.google.gerrit.extensions.validators.CommentValidator;
import com.google.gerrit.metrics.Counter1;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Field;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.PublishCommentUtil;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.EmailReviewComments;
import com.google.gerrit.server.change.ModifyReviewersEmail;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.ReviewerModifier;
@@ -106,12 +86,10 @@
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.change.WorkInProgressOp;
import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.extensions.events.ReviewerAdded;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.patch.DiffSummary;
import com.google.gerrit.server.patch.DiffSummaryKey;
import com.google.gerrit.server.patch.PatchListCache;
@@ -121,26 +99,17 @@
import com.google.gerrit.server.permissions.LabelPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
@@ -150,7 +119,6 @@
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
-import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
@@ -186,21 +154,15 @@
public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
"work_in_progress and ready are mutually exclusive";
- public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
private final BatchUpdate.Factory updateFactory;
+ private final PostReviewOp.Factory postReviewOpFactory;
private final ChangeResource.Factory changeResourceFactory;
private final ChangeData.Factory changeDataFactory;
private final AccountCache accountCache;
private final ApprovalsUtil approvalsUtil;
- private final ChangeMessagesUtil cmUtil;
private final CommentsUtil commentsUtil;
- private final PublishCommentUtil publishCommentUtil;
- private final PatchSetUtil psUtil;
private final PatchListCache patchListCache;
private final AccountResolver accountResolver;
- private final EmailReviewComments.Factory email;
- private final CommentAdded commentAdded;
private final ReviewerModifier reviewerModifier;
private final Metrics metrics;
private final ModifyReviewersEmail modifyReviewersEmail;
@@ -208,28 +170,22 @@
private final WorkInProgressOp.Factory workInProgressOpFactory;
private final ProjectCache projectCache;
private final PermissionBackend permissionBackend;
- private final PluginSetContext<CommentValidator> commentValidators;
- private final PluginSetContext<OnPostReview> onPostReviews;
+
private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
private final ReviewerAdded reviewerAdded;
private final boolean strictLabels;
- private final boolean publishPatchSetLevelComment;
@Inject
PostReview(
BatchUpdate.Factory updateFactory,
+ PostReviewOp.Factory postReviewOpFactory,
ChangeResource.Factory changeResourceFactory,
ChangeData.Factory changeDataFactory,
AccountCache accountCache,
ApprovalsUtil approvalsUtil,
- ChangeMessagesUtil cmUtil,
CommentsUtil commentsUtil,
- PublishCommentUtil publishCommentUtil,
- PatchSetUtil psUtil,
PatchListCache patchListCache,
AccountResolver accountResolver,
- EmailReviewComments.Factory email,
- CommentAdded commentAdded,
ReviewerModifier reviewerModifier,
Metrics metrics,
ModifyReviewersEmail modifyReviewersEmail,
@@ -238,23 +194,17 @@
WorkInProgressOp.Factory workInProgressOpFactory,
ProjectCache projectCache,
PermissionBackend permissionBackend,
- PluginSetContext<CommentValidator> commentValidators,
- PluginSetContext<OnPostReview> onPostReviews,
ReplyAttentionSetUpdates replyAttentionSetUpdates,
ReviewerAdded reviewerAdded) {
this.updateFactory = updateFactory;
+ this.postReviewOpFactory = postReviewOpFactory;
this.changeResourceFactory = changeResourceFactory;
this.changeDataFactory = changeDataFactory;
this.accountCache = accountCache;
this.commentsUtil = commentsUtil;
- this.publishCommentUtil = publishCommentUtil;
- this.psUtil = psUtil;
this.patchListCache = patchListCache;
this.approvalsUtil = approvalsUtil;
- this.cmUtil = cmUtil;
this.accountResolver = accountResolver;
- this.email = email;
- this.commentAdded = commentAdded;
this.reviewerModifier = reviewerModifier;
this.metrics = metrics;
this.modifyReviewersEmail = modifyReviewersEmail;
@@ -262,13 +212,9 @@
this.workInProgressOpFactory = workInProgressOpFactory;
this.projectCache = projectCache;
this.permissionBackend = permissionBackend;
- this.commentValidators = commentValidators;
- this.onPostReviews = onPostReviews;
this.replyAttentionSetUpdates = replyAttentionSetUpdates;
this.reviewerAdded = reviewerAdded;
this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
- this.publishPatchSetLevelComment =
- gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
}
@Override
@@ -432,8 +378,9 @@
// Add the review op.
logger.atFine().log("posting review");
- bu.addOp(
- revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
+ PostReviewOp postReviewOp =
+ postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
+ bu.addOp(revision.getChange().getId(), postReviewOp);
// Adjust the attention set based on the input
replyAttentionSetUpdates.updateAttentionSet(
@@ -488,7 +435,9 @@
Change change,
List<ReviewerModification> reviewerModifications,
NotifyResolver.Result notify) {
- try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
+ try (TraceContext.TraceTimer ignored =
+ TraceContext.newTimer(
+ getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) {
List<Account.Id> to = new ArrayList<>();
List<Account.Id> cc = new ArrayList<>();
List<Account.Id> removed = new ArrayList<>();
@@ -699,10 +648,6 @@
.collect(toList());
}
- private TraceContext.TraceTimer newTimer(String method) {
- return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
- }
-
private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
RevisionResource revision, Map<String, List<T>> commentsPerPath)
throws BadRequestException, PatchListNotAvailableException {
@@ -1009,645 +954,4 @@
@Nullable
abstract Comment.Range range();
}
-
- private class Op implements BatchUpdateOp {
- private final ProjectState projectState;
- private final PatchSet.Id psId;
- private final ReviewInput in;
-
- private IdentifiedUser user;
- private ChangeNotes notes;
- private PatchSet ps;
- private String mailMessage;
- private List<Comment> comments = new ArrayList<>();
- private List<LabelVote> labelDelta = new ArrayList<>();
- private Map<String, Short> approvals = new HashMap<>();
- private Map<String, Short> oldApprovals = new HashMap<>();
-
- private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
- this.projectState = projectState;
- this.psId = psId;
- this.in = in;
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx)
- throws ResourceConflictException, UnprocessableEntityException, IOException,
- CommentsRejectedException {
- user = ctx.getIdentifiedUser();
- notes = ctx.getNotes();
- ps = psUtil.get(ctx.getNotes(), psId);
- List<RobotComment> newRobotComments =
- in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
- boolean dirty = false;
- try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
- dirty |= insertComments(ctx, newRobotComments);
- }
- try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
- dirty |= insertRobotComments(ctx, newRobotComments);
- }
- try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
- dirty |= updateLabels(projectState, ctx);
- }
- try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
- dirty |= insertMessage(ctx);
- }
- return dirty;
- }
-
- @Override
- public void postUpdate(PostUpdateContext ctx) {
- if (mailMessage == null) {
- return;
- }
- NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
- if (notify.shouldNotify()) {
- try {
- email
- .create(
- notify,
- notes,
- ps,
- user,
- mailMessage,
- ctx.getWhen(),
- comments,
- in.message,
- labelDelta,
- ctx.getRepoView())
- .sendAsync();
- } catch (IOException ex) {
- throw new StorageException(
- String.format("Repository %s not found", ctx.getProject().get()), ex);
- }
- }
- String comment = mailMessage;
- if (publishPatchSetLevelComment) {
- // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
- // added event. For backwards compatibility, patchset level comment has a higher priority
- // than change message and should be used as comment in comment added event.
- if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
- List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
- if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
- CommentInput firstComment = patchSetLevelComments.get(0);
- if (!Strings.isNullOrEmpty(firstComment.message)) {
- comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
- }
- }
- }
- }
- commentAdded.fire(
- ctx.getChangeData(notes),
- ps,
- user.state(),
- comment,
- approvals,
- oldApprovals,
- ctx.getWhen());
- }
-
- /**
- * Publishes draft and input comments. Input comments are those passed as input in the request
- * body.
- *
- * @param ctx context for performing the change update.
- * @param newRobotComments robot comments. Used only for validation in this method.
- * @return true if any input comments where published.
- */
- private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
- throws CommentsRejectedException {
- Map<String, List<CommentInput>> inputComments = in.comments;
- if (inputComments == null) {
- inputComments = Collections.emptyMap();
- }
-
- // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
- Map<String, HumanComment> drafts = new HashMap<>();
-
- if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
- drafts =
- in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
- ? changeDrafts(ctx)
- : patchSetDrafts(ctx);
- }
-
- // Existing published comments
- Set<CommentSetEntry> existingComments =
- in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
-
- // Input comments should be deduplicated from existing drafts
- List<HumanComment> inputCommentsToPublish =
- resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
-
- switch (in.drafts) {
- case PUBLISH:
- case PUBLISH_ALL_REVISIONS:
- Collection<HumanComment> filteredDrafts =
- in.draftIdsToPublish == null
- ? drafts.values()
- : drafts.values().stream()
- .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
- .collect(Collectors.toList());
-
- validateComments(
- ctx,
- Streams.concat(
- drafts.values().stream(),
- inputCommentsToPublish.stream(),
- newRobotComments.stream()));
- publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
- comments.addAll(drafts.values());
- break;
- case KEEP:
- validateComments(
- ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
- break;
- }
- commentsUtil.putHumanComments(
- ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
- comments.addAll(inputCommentsToPublish);
- return !inputCommentsToPublish.isEmpty();
- }
-
- /**
- * Returns the subset of {@code inputComments} that do not have a matching comment (with same
- * id) neither in {@code existingComments} nor in {@code drafts}.
- *
- * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
- * removed.
- *
- * @param inputComments new comments provided as {@link CommentInput} entries in the API.
- * @param existingComments existing published comments in the database.
- * @param drafts existing draft comments in the database. This map can be modified.
- */
- private List<HumanComment> resolveInputCommentsAndDrafts(
- Map<String, List<CommentInput>> inputComments,
- Set<CommentSetEntry> existingComments,
- Map<String, HumanComment> drafts,
- ChangeContext ctx) {
- List<HumanComment> inputCommentsToPublish = new ArrayList<>();
- for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
- String path = entry.getKey();
- for (CommentInput inputComment : entry.getValue()) {
- HumanComment comment = drafts.remove(Url.decode(inputComment.id));
- if (comment == null) {
- String parent = Url.decode(inputComment.inReplyTo);
- comment =
- commentsUtil.newHumanComment(
- ctx.getNotes(),
- ctx.getUser(),
- ctx.getWhen(),
- path,
- psId,
- inputComment.side(),
- inputComment.message,
- inputComment.unresolved,
- parent);
- } else {
- // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
- comment.writtenOn = Timestamp.from(ctx.getWhen());
- comment.side = inputComment.side();
- comment.message = inputComment.message;
- }
-
- commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
- comment.setLineNbrAndRange(inputComment.line, inputComment.range);
- comment.tag = in.tag;
-
- if (existingComments.contains(CommentSetEntry.create(comment))) {
- continue;
- }
- inputCommentsToPublish.add(comment);
- }
- }
- return inputCommentsToPublish;
- }
-
- /**
- * Validates all comments and the change message in a single call to fulfill the interface
- * contract of {@link CommentValidator#validateComments(CommentValidationContext,
- * ImmutableList)}.
- */
- private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
- throws CommentsRejectedException {
- CommentValidationContext commentValidationCtx =
- CommentValidationContext.create(
- ctx.getChange().getChangeId(),
- ctx.getChange().getProject().get(),
- ctx.getChange().getDest().branch());
- String changeMessage = Strings.nullToEmpty(in.message).trim();
- ImmutableList<CommentForValidation> draftsForValidation =
- Stream.concat(
- comments.map(
- comment ->
- CommentForValidation.create(
- comment instanceof RobotComment
- ? CommentForValidation.CommentSource.ROBOT
- : CommentForValidation.CommentSource.HUMAN,
- comment.lineNbr > 0
- ? CommentForValidation.CommentType.INLINE_COMMENT
- : CommentForValidation.CommentType.FILE_COMMENT,
- comment.message,
- comment.getApproximateSize())),
- Stream.of(
- CommentForValidation.create(
- CommentForValidation.CommentSource.HUMAN,
- CommentForValidation.CommentType.CHANGE_MESSAGE,
- changeMessage,
- changeMessage.length())))
- .collect(toImmutableList());
- ImmutableList<CommentValidationFailure> draftValidationFailures =
- PublishCommentUtil.findInvalidComments(
- commentValidationCtx, commentValidators, draftsForValidation);
- if (!draftValidationFailures.isEmpty()) {
- throw new CommentsRejectedException(draftValidationFailures);
- }
- }
-
- private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
- if (in.robotComments == null) {
- return false;
- }
- commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
- comments.addAll(newRobotComments);
- return !newRobotComments.isEmpty();
- }
-
- private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
- List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
-
- Set<CommentSetEntry> existingIds =
- in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
-
- for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
- String path = ent.getKey();
- for (RobotCommentInput c : ent.getValue()) {
- RobotComment e = createRobotCommentFromInput(ctx, path, c);
- if (existingIds.contains(CommentSetEntry.create(e))) {
- continue;
- }
- toAdd.add(e);
- }
- }
- return toAdd;
- }
-
- private RobotComment createRobotCommentFromInput(
- ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
- RobotComment robotComment =
- commentsUtil.newRobotComment(
- ctx,
- path,
- psId,
- robotCommentInput.side(),
- robotCommentInput.message,
- robotCommentInput.robotId,
- robotCommentInput.robotRunId);
- robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
- robotComment.url = robotCommentInput.url;
- robotComment.properties = robotCommentInput.properties;
- robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
- robotComment.tag = in.tag;
- commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
- robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
- return robotComment;
- }
-
- private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
- List<FixSuggestionInfo> fixSuggestionInfos) {
- if (fixSuggestionInfos == null) {
- return ImmutableList.of();
- }
-
- ImmutableList.Builder<FixSuggestion> fixSuggestions =
- ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
- for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
- fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
- }
- return fixSuggestions.build();
- }
-
- private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
- List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
- String fixId = ChangeUtil.messageUuid();
- return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
- }
-
- private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
- return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
- }
-
- private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
- Comment.Range range = new Comment.Range(fixReplacementInfo.range);
- return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
- }
-
- private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
- return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
- .map(CommentSetEntry::create)
- .collect(toSet());
- }
-
- private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
- return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
- .map(CommentSetEntry::create)
- .collect(toSet());
- }
-
- private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
- return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
- .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
- }
-
- private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
- return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
- .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
- }
-
- private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
- Map<String, Short> labels = new HashMap<>();
- for (PatchSetApproval psa : patchsetApprovals) {
- labels.put(psa.label(), psa.value());
- }
- return labels;
- }
-
- private Map<String, Short> getAllApprovals(
- LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
- Map<String, Short> allApprovals = new HashMap<>();
- for (LabelType lt : labelTypes.getLabelTypes()) {
- allApprovals.put(lt.getName(), (short) 0);
- }
- // set approvals to existing votes
- if (current != null) {
- allApprovals.putAll(current);
- }
- // set approvals to new votes
- if (input != null) {
- allApprovals.putAll(input);
- }
- return allApprovals;
- }
-
- private Map<String, Short> getPreviousApprovals(
- Map<String, Short> allApprovals, Map<String, Short> current) {
- Map<String, Short> previous = new HashMap<>();
- for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
- // assume vote is 0 if there is no vote
- if (!current.containsKey(approval.getKey())) {
- previous.put(approval.getKey(), (short) 0);
- } else {
- previous.put(approval.getKey(), current.get(approval.getKey()));
- }
- }
- return previous;
- }
-
- private boolean isReviewer(ChangeContext ctx) {
- return approvalsUtil
- .getReviewers(ctx.getNotes())
- .byState(REVIEWER)
- .contains(ctx.getAccountId());
- }
-
- private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
- throws ResourceConflictException {
- Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
-
- // If no labels were modified and change is closed, abort early.
- // This avoids trying to record a modified label caused by a user
- // losing access to a label after the change was submitted.
- if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
- return false;
- }
-
- List<PatchSetApproval> del = new ArrayList<>();
- List<PatchSetApproval> ups = new ArrayList<>();
- Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
- LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
- Map<String, Short> allApprovals =
- getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
- Map<String, Short> previous =
- getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
-
- ChangeUpdate update = ctx.getUpdate(psId);
- for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
- String name = ent.getKey();
- LabelType lt =
- labelTypes
- .byLabel(name)
- .orElseThrow(() -> new IllegalStateException("no label config for " + name));
-
- PatchSetApproval c = current.remove(lt.getName());
- String normName = lt.getName();
- approvals.put(normName, (short) 0);
- if (ent.getValue() == null || ent.getValue() == 0) {
- // User requested delete of this label.
- oldApprovals.put(normName, null);
- if (c != null) {
- if (c.value() != 0) {
- addLabelDelta(normName, (short) 0);
- oldApprovals.put(normName, previous.get(normName));
- }
- del.add(c);
- update.putApproval(normName, (short) 0);
- }
- // Only allow voting again if the vote is copied over from a past patch-set, or the
- // values are different.
- } else if (c != null
- && (c.value() != ent.getValue()
- || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
- PatchSetApproval.Builder b =
- c.toBuilder()
- .value(ent.getValue())
- .granted(ctx.getWhen())
- .tag(Optional.ofNullable(in.tag));
- ctx.getUser().updateRealAccountId(b::realAccountId);
- c = b.build();
- ups.add(c);
- addLabelDelta(normName, c.value());
- oldApprovals.put(normName, previous.get(normName));
- approvals.put(normName, c.value());
- update.putApproval(normName, ent.getValue());
- } else if (c != null && c.value() == ent.getValue()) {
- current.put(normName, c);
- oldApprovals.put(normName, null);
- approvals.put(normName, c.value());
- } else if (c == null) {
- c =
- ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
- .tag(Optional.ofNullable(in.tag))
- .granted(ctx.getWhen())
- .build();
- ups.add(c);
- addLabelDelta(normName, c.value());
- oldApprovals.put(normName, previous.get(normName));
- approvals.put(normName, c.value());
- update.putReviewer(user.getAccountId(), REVIEWER);
- update.putApproval(normName, ent.getValue());
- }
- }
-
- validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
-
- // Return early if user is not a reviewer and not posting any labels.
- // This allows us to preserve their CC status.
- if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
- return false;
- }
-
- return !del.isEmpty() || !ups.isEmpty();
- }
-
- /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
- private boolean isApprovalCopiedOver(
- PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
- return !changeNotes.getApprovals().onlyNonCopied()
- .get(changeNotes.getChange().currentPatchSetId()).stream()
- .anyMatch(p -> p.equals(patchSetApproval));
- }
-
- private void validatePostSubmitLabels(
- ChangeContext ctx,
- LabelTypes labelTypes,
- Map<String, Short> previous,
- List<PatchSetApproval> ups,
- List<PatchSetApproval> del)
- throws ResourceConflictException {
- if (ctx.getChange().isNew()) {
- return; // Not closed, nothing to validate.
- } else if (del.isEmpty() && ups.isEmpty()) {
- return; // No new votes.
- } else if (!ctx.getChange().isMerged()) {
- throw new ResourceConflictException("change is closed");
- }
-
- // Disallow reducing votes on any labels post-submit. This assumes the
- // high values were broadly necessary to submit, so reducing them would
- // make it possible to take a merged change and make it no longer
- // submittable.
- List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
- List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
-
- for (PatchSetApproval psa : del) {
- LabelType lt =
- labelTypes
- .byLabel(psa.label())
- .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
- String normName = lt.getName();
- if (!lt.isAllowPostSubmit()) {
- disallowed.add(normName);
- }
- Short prev = previous.get(normName);
- if (prev != null && prev != 0) {
- reduced.add(psa);
- }
- }
-
- for (PatchSetApproval psa : ups) {
- LabelType lt =
- labelTypes
- .byLabel(psa.label())
- .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
- String normName = lt.getName();
- if (!lt.isAllowPostSubmit()) {
- disallowed.add(normName);
- }
- Short prev = previous.get(normName);
- if (prev == null) {
- continue;
- }
- if (prev > psa.value()) {
- reduced.add(psa);
- }
- // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
- }
-
- if (!disallowed.isEmpty()) {
- throw new ResourceConflictException(
- "Voting on labels disallowed after submit: "
- + disallowed.stream().distinct().sorted().collect(joining(", ")));
- }
- if (!reduced.isEmpty()) {
- throw new ResourceConflictException(
- "Cannot reduce vote on labels for closed change: "
- + reduced.stream()
- .map(PatchSetApproval::label)
- .distinct()
- .sorted()
- .collect(joining(", ")));
- }
- }
-
- private Map<String, PatchSetApproval> scanLabels(
- ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
- LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
- Map<String, PatchSetApproval> current = new HashMap<>();
-
- for (PatchSetApproval a :
- approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
- if (a.isLegacySubmit()) {
- continue;
- }
-
- Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
- if (lt.isPresent()) {
- current.put(lt.get().getName(), a);
- } else {
- del.add(a);
- }
- }
- return current;
- }
-
- private boolean insertMessage(ChangeContext ctx) {
- String msg = Strings.nullToEmpty(in.message).trim();
-
- StringBuilder buf = new StringBuilder();
- for (LabelVote d : labelDelta) {
- buf.append(" ").append(d.format());
- }
- if (comments.size() == 1) {
- buf.append("\n\n(1 comment)");
- } else if (comments.size() > 1) {
- buf.append(String.format("\n\n(%d comments)", comments.size()));
- }
- if (!msg.isEmpty()) {
- // Message was already validated when validating comments, since validators need to see
- // everything in a single call.
- buf.append("\n\n").append(msg);
- } else if (in.ready) {
- buf.append("\n\n" + START_REVIEW_MESSAGE);
- }
-
- List<String> pluginMessages = new ArrayList<>();
- onPostReviews.runEach(
- onPostReview ->
- onPostReview
- .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
- .ifPresent(
- pluginMessage ->
- pluginMessages.add(
- !pluginMessage.endsWith("\n")
- ? pluginMessage + "\n"
- : pluginMessage)));
- if (!pluginMessages.isEmpty()) {
- buf.append("\n\n");
- buf.append(Joiner.on("\n").join(pluginMessages));
- }
-
- if (buf.length() == 0) {
- return false;
- }
-
- mailMessage =
- cmUtil.setChangeMessage(
- ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
- return true;
- }
-
- private void addLabelDelta(String name, short value) {
- labelDelta.add(LabelVote.create(name, value));
- }
- }
}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
new file mode 100644
index 0000000..c3ab94c1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -0,0 +1,774 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+public class PostReviewOp implements BatchUpdateOp {
+ interface Factory {
+ PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+ }
+
+ @VisibleForTesting
+ public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+ private final ApprovalsUtil approvalsUtil;
+ private final ChangeMessagesUtil cmUtil;
+ private final CommentsUtil commentsUtil;
+ private final PublishCommentUtil publishCommentUtil;
+ private final PatchSetUtil psUtil;
+ private final EmailReviewComments.Factory email;
+ private final CommentAdded commentAdded;
+ private final PluginSetContext<CommentValidator> commentValidators;
+ private final PluginSetContext<OnPostReview> onPostReviews;
+
+ private final ProjectState projectState;
+ private final PatchSet.Id psId;
+ private final ReviewInput in;
+ private final boolean publishPatchSetLevelComment;
+
+ private IdentifiedUser user;
+ private ChangeNotes notes;
+ private PatchSet ps;
+ private String mailMessage;
+ private List<Comment> comments = new ArrayList<>();
+ private List<LabelVote> labelDelta = new ArrayList<>();
+ private Map<String, Short> approvals = new HashMap<>();
+ private Map<String, Short> oldApprovals = new HashMap<>();
+
+ @Inject
+ PostReviewOp(
+ @GerritServerConfig Config gerritConfig,
+ ApprovalsUtil approvalsUtil,
+ ChangeMessagesUtil cmUtil,
+ CommentsUtil commentsUtil,
+ PublishCommentUtil publishCommentUtil,
+ PatchSetUtil psUtil,
+ EmailReviewComments.Factory email,
+ CommentAdded commentAdded,
+ PluginSetContext<CommentValidator> commentValidators,
+ PluginSetContext<OnPostReview> onPostReviews,
+ @Assisted ProjectState projectState,
+ @Assisted PatchSet.Id psId,
+ @Assisted ReviewInput in) {
+ this.approvalsUtil = approvalsUtil;
+ this.publishCommentUtil = publishCommentUtil;
+ this.psUtil = psUtil;
+ this.cmUtil = cmUtil;
+ this.commentsUtil = commentsUtil;
+ this.email = email;
+ this.commentAdded = commentAdded;
+ this.commentValidators = commentValidators;
+ this.onPostReviews = onPostReviews;
+ this.publishPatchSetLevelComment =
+ gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
+
+ this.projectState = projectState;
+ this.psId = psId;
+ this.in = in;
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext ctx)
+ throws ResourceConflictException, UnprocessableEntityException, IOException,
+ CommentsRejectedException {
+ user = ctx.getIdentifiedUser();
+ notes = ctx.getNotes();
+ ps = psUtil.get(ctx.getNotes(), psId);
+ List<RobotComment> newRobotComments =
+ in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+ boolean dirty = false;
+ try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
+ dirty |= insertComments(ctx, newRobotComments);
+ }
+ try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
+ dirty |= insertRobotComments(ctx, newRobotComments);
+ }
+ try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
+ dirty |= updateLabels(projectState, ctx);
+ }
+ try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
+ dirty |= insertMessage(ctx);
+ }
+ return dirty;
+ }
+
+ @Override
+ public void postUpdate(PostUpdateContext ctx) {
+ if (mailMessage == null) {
+ return;
+ }
+ NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
+ if (notify.shouldNotify()) {
+ try {
+ email
+ .create(
+ notify,
+ notes,
+ ps,
+ user,
+ mailMessage,
+ ctx.getWhen(),
+ comments,
+ in.message,
+ labelDelta,
+ ctx.getRepoView())
+ .sendAsync();
+ } catch (IOException ex) {
+ throw new StorageException(
+ String.format("Repository %s not found", ctx.getProject().get()), ex);
+ }
+ }
+ String comment = mailMessage;
+ if (publishPatchSetLevelComment) {
+ // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
+ // added event. For backwards compatibility, patchset level comment has a higher priority
+ // than change message and should be used as comment in comment added event.
+ if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
+ List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
+ if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
+ CommentInput firstComment = patchSetLevelComments.get(0);
+ if (!Strings.isNullOrEmpty(firstComment.message)) {
+ comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
+ }
+ }
+ }
+ }
+ commentAdded.fire(
+ ctx.getChangeData(notes),
+ ps,
+ user.state(),
+ comment,
+ approvals,
+ oldApprovals,
+ ctx.getWhen());
+ }
+
+ /**
+ * Publishes draft and input comments. Input comments are those passed as input in the request
+ * body.
+ *
+ * @param ctx context for performing the change update.
+ * @param newRobotComments robot comments. Used only for validation in this method.
+ * @return true if any input comments where published.
+ */
+ private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
+ throws CommentsRejectedException {
+ Map<String, List<CommentInput>> inputComments = in.comments;
+ if (inputComments == null) {
+ inputComments = Collections.emptyMap();
+ }
+
+ // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
+ Map<String, HumanComment> drafts = new HashMap<>();
+
+ if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
+ drafts =
+ in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+ ? changeDrafts(ctx)
+ : patchSetDrafts(ctx);
+ }
+
+ // Existing published comments
+ Set<CommentSetEntry> existingComments =
+ in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
+
+ // Input comments should be deduplicated from existing drafts
+ List<HumanComment> inputCommentsToPublish =
+ resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+ switch (in.drafts) {
+ case PUBLISH:
+ case PUBLISH_ALL_REVISIONS:
+ Collection<HumanComment> filteredDrafts =
+ in.draftIdsToPublish == null
+ ? drafts.values()
+ : drafts.values().stream()
+ .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+ .collect(Collectors.toList());
+
+ validateComments(
+ ctx,
+ Streams.concat(
+ drafts.values().stream(),
+ inputCommentsToPublish.stream(),
+ newRobotComments.stream()));
+ publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+ comments.addAll(drafts.values());
+ break;
+ case KEEP:
+ validateComments(
+ ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+ break;
+ }
+ commentsUtil.putHumanComments(
+ ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+ comments.addAll(inputCommentsToPublish);
+ return !inputCommentsToPublish.isEmpty();
+ }
+
+ /**
+ * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
+ * neither in {@code existingComments} nor in {@code drafts}.
+ *
+ * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+ * removed.
+ *
+ * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+ * @param existingComments existing published comments in the database.
+ * @param drafts existing draft comments in the database. This map can be modified.
+ */
+ private List<HumanComment> resolveInputCommentsAndDrafts(
+ Map<String, List<CommentInput>> inputComments,
+ Set<CommentSetEntry> existingComments,
+ Map<String, HumanComment> drafts,
+ ChangeContext ctx) {
+ List<HumanComment> inputCommentsToPublish = new ArrayList<>();
+ for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+ String path = entry.getKey();
+ for (CommentInput inputComment : entry.getValue()) {
+ HumanComment comment = drafts.remove(Url.decode(inputComment.id));
+ if (comment == null) {
+ String parent = Url.decode(inputComment.inReplyTo);
+ comment =
+ commentsUtil.newHumanComment(
+ ctx.getNotes(),
+ ctx.getUser(),
+ ctx.getWhen(),
+ path,
+ psId,
+ inputComment.side(),
+ inputComment.message,
+ inputComment.unresolved,
+ parent);
+ } else {
+ // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+ comment.writtenOn = Timestamp.from(ctx.getWhen());
+ comment.side = inputComment.side();
+ comment.message = inputComment.message;
+ }
+
+ commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+ comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+ comment.tag = in.tag;
+
+ if (existingComments.contains(CommentSetEntry.create(comment))) {
+ continue;
+ }
+ inputCommentsToPublish.add(comment);
+ }
+ }
+ return inputCommentsToPublish;
+ }
+
+ /**
+ * Validates all comments and the change message in a single call to fulfill the interface
+ * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
+ */
+ private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
+ throws CommentsRejectedException {
+ CommentValidationContext commentValidationCtx =
+ CommentValidationContext.create(
+ ctx.getChange().getChangeId(),
+ ctx.getChange().getProject().get(),
+ ctx.getChange().getDest().branch());
+ String changeMessage = Strings.nullToEmpty(in.message).trim();
+ ImmutableList<CommentForValidation> draftsForValidation =
+ Stream.concat(
+ comments.map(
+ comment ->
+ CommentForValidation.create(
+ comment instanceof RobotComment
+ ? CommentForValidation.CommentSource.ROBOT
+ : CommentForValidation.CommentSource.HUMAN,
+ comment.lineNbr > 0
+ ? CommentForValidation.CommentType.INLINE_COMMENT
+ : CommentForValidation.CommentType.FILE_COMMENT,
+ comment.message,
+ comment.getApproximateSize())),
+ Stream.of(
+ CommentForValidation.create(
+ CommentForValidation.CommentSource.HUMAN,
+ CommentForValidation.CommentType.CHANGE_MESSAGE,
+ changeMessage,
+ changeMessage.length())))
+ .collect(toImmutableList());
+ ImmutableList<CommentValidationFailure> draftValidationFailures =
+ PublishCommentUtil.findInvalidComments(
+ commentValidationCtx, commentValidators, draftsForValidation);
+ if (!draftValidationFailures.isEmpty()) {
+ throw new CommentsRejectedException(draftValidationFailures);
+ }
+ }
+
+ private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
+ if (in.robotComments == null) {
+ return false;
+ }
+ commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+ comments.addAll(newRobotComments);
+ return !newRobotComments.isEmpty();
+ }
+
+ private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
+ List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+ Set<CommentSetEntry> existingIds =
+ in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+ for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+ String path = ent.getKey();
+ for (RobotCommentInput c : ent.getValue()) {
+ RobotComment e = createRobotCommentFromInput(ctx, path, c);
+ if (existingIds.contains(CommentSetEntry.create(e))) {
+ continue;
+ }
+ toAdd.add(e);
+ }
+ }
+ return toAdd;
+ }
+
+ private RobotComment createRobotCommentFromInput(
+ ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
+ RobotComment robotComment =
+ commentsUtil.newRobotComment(
+ ctx,
+ path,
+ psId,
+ robotCommentInput.side(),
+ robotCommentInput.message,
+ robotCommentInput.robotId,
+ robotCommentInput.robotRunId);
+ robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+ robotComment.url = robotCommentInput.url;
+ robotComment.properties = robotCommentInput.properties;
+ robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+ robotComment.tag = in.tag;
+ commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
+ robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+ return robotComment;
+ }
+
+ private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
+ List<FixSuggestionInfo> fixSuggestionInfos) {
+ if (fixSuggestionInfos == null) {
+ return ImmutableList.of();
+ }
+
+ ImmutableList.Builder<FixSuggestion> fixSuggestions =
+ ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
+ for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+ fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+ }
+ return fixSuggestions.build();
+ }
+
+ private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+ List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+ String fixId = ChangeUtil.messageUuid();
+ return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+ }
+
+ private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+ return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+ }
+
+ private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+ Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+ return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+ }
+
+ private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
+ return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
+ .map(CommentSetEntry::create)
+ .collect(toSet());
+ }
+
+ private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
+ return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
+ .map(CommentSetEntry::create)
+ .collect(toSet());
+ }
+
+ private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
+ return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+ .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+ }
+
+ private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
+ return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+ .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+ }
+
+ private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
+ Map<String, Short> labels = new HashMap<>();
+ for (PatchSetApproval psa : patchsetApprovals) {
+ labels.put(psa.label(), psa.value());
+ }
+ return labels;
+ }
+
+ private Map<String, Short> getAllApprovals(
+ LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
+ Map<String, Short> allApprovals = new HashMap<>();
+ for (LabelType lt : labelTypes.getLabelTypes()) {
+ allApprovals.put(lt.getName(), (short) 0);
+ }
+ // set approvals to existing votes
+ if (current != null) {
+ allApprovals.putAll(current);
+ }
+ // set approvals to new votes
+ if (input != null) {
+ allApprovals.putAll(input);
+ }
+ return allApprovals;
+ }
+
+ private Map<String, Short> getPreviousApprovals(
+ Map<String, Short> allApprovals, Map<String, Short> current) {
+ Map<String, Short> previous = new HashMap<>();
+ for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+ // assume vote is 0 if there is no vote
+ if (!current.containsKey(approval.getKey())) {
+ previous.put(approval.getKey(), (short) 0);
+ } else {
+ previous.put(approval.getKey(), current.get(approval.getKey()));
+ }
+ }
+ return previous;
+ }
+
+ private boolean isReviewer(ChangeContext ctx) {
+ return approvalsUtil
+ .getReviewers(ctx.getNotes())
+ .byState(REVIEWER)
+ .contains(ctx.getAccountId());
+ }
+
+ private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+ throws ResourceConflictException {
+ Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
+
+ // If no labels were modified and change is closed, abort early.
+ // This avoids trying to record a modified label caused by a user
+ // losing access to a label after the change was submitted.
+ if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
+ return false;
+ }
+
+ List<PatchSetApproval> del = new ArrayList<>();
+ List<PatchSetApproval> ups = new ArrayList<>();
+ Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+ LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+ Map<String, Short> allApprovals =
+ getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+ Map<String, Short> previous =
+ getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
+
+ ChangeUpdate update = ctx.getUpdate(psId);
+ for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+ String name = ent.getKey();
+ LabelType lt =
+ labelTypes
+ .byLabel(name)
+ .orElseThrow(() -> new IllegalStateException("no label config for " + name));
+
+ PatchSetApproval c = current.remove(lt.getName());
+ String normName = lt.getName();
+ approvals.put(normName, (short) 0);
+ if (ent.getValue() == null || ent.getValue() == 0) {
+ // User requested delete of this label.
+ oldApprovals.put(normName, null);
+ if (c != null) {
+ if (c.value() != 0) {
+ addLabelDelta(normName, (short) 0);
+ oldApprovals.put(normName, previous.get(normName));
+ }
+ del.add(c);
+ update.putApproval(normName, (short) 0);
+ }
+ // Only allow voting again if the vote is copied over from a past patch-set, or the
+ // values are different.
+ } else if (c != null
+ && (c.value() != ent.getValue()
+ || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
+ PatchSetApproval.Builder b =
+ c.toBuilder()
+ .value(ent.getValue())
+ .granted(ctx.getWhen())
+ .tag(Optional.ofNullable(in.tag));
+ ctx.getUser().updateRealAccountId(b::realAccountId);
+ c = b.build();
+ ups.add(c);
+ addLabelDelta(normName, c.value());
+ oldApprovals.put(normName, previous.get(normName));
+ approvals.put(normName, c.value());
+ update.putApproval(normName, ent.getValue());
+ } else if (c != null && c.value() == ent.getValue()) {
+ current.put(normName, c);
+ oldApprovals.put(normName, null);
+ approvals.put(normName, c.value());
+ } else if (c == null) {
+ c =
+ ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+ .tag(Optional.ofNullable(in.tag))
+ .granted(ctx.getWhen())
+ .build();
+ ups.add(c);
+ addLabelDelta(normName, c.value());
+ oldApprovals.put(normName, previous.get(normName));
+ approvals.put(normName, c.value());
+ update.putReviewer(user.getAccountId(), REVIEWER);
+ update.putApproval(normName, ent.getValue());
+ }
+ }
+
+ validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+ // Return early if user is not a reviewer and not posting any labels.
+ // This allows us to preserve their CC status.
+ if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+ return false;
+ }
+
+ return !del.isEmpty() || !ups.isEmpty();
+ }
+
+ /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
+ private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+ return !changeNotes.getApprovals().onlyNonCopied()
+ .get(changeNotes.getChange().currentPatchSetId()).stream()
+ .anyMatch(p -> p.equals(patchSetApproval));
+ }
+
+ private void validatePostSubmitLabels(
+ ChangeContext ctx,
+ LabelTypes labelTypes,
+ Map<String, Short> previous,
+ List<PatchSetApproval> ups,
+ List<PatchSetApproval> del)
+ throws ResourceConflictException {
+ if (ctx.getChange().isNew()) {
+ return; // Not closed, nothing to validate.
+ } else if (del.isEmpty() && ups.isEmpty()) {
+ return; // No new votes.
+ } else if (!ctx.getChange().isMerged()) {
+ throw new ResourceConflictException("change is closed");
+ }
+
+ // Disallow reducing votes on any labels post-submit. This assumes the
+ // high values were broadly necessary to submit, so reducing them would
+ // make it possible to take a merged change and make it no longer
+ // submittable.
+ List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+ List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+ for (PatchSetApproval psa : del) {
+ LabelType lt =
+ labelTypes
+ .byLabel(psa.label())
+ .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+ String normName = lt.getName();
+ if (!lt.isAllowPostSubmit()) {
+ disallowed.add(normName);
+ }
+ Short prev = previous.get(normName);
+ if (prev != null && prev != 0) {
+ reduced.add(psa);
+ }
+ }
+
+ for (PatchSetApproval psa : ups) {
+ LabelType lt =
+ labelTypes
+ .byLabel(psa.label())
+ .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+ String normName = lt.getName();
+ if (!lt.isAllowPostSubmit()) {
+ disallowed.add(normName);
+ }
+ Short prev = previous.get(normName);
+ if (prev == null) {
+ continue;
+ }
+ if (prev > psa.value()) {
+ reduced.add(psa);
+ }
+ // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
+ }
+
+ if (!disallowed.isEmpty()) {
+ throw new ResourceConflictException(
+ "Voting on labels disallowed after submit: "
+ + disallowed.stream().distinct().sorted().collect(joining(", ")));
+ }
+ if (!reduced.isEmpty()) {
+ throw new ResourceConflictException(
+ "Cannot reduce vote on labels for closed change: "
+ + reduced.stream()
+ .map(PatchSetApproval::label)
+ .distinct()
+ .sorted()
+ .collect(joining(", ")));
+ }
+ }
+
+ private Map<String, PatchSetApproval> scanLabels(
+ ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
+ LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+ Map<String, PatchSetApproval> current = new HashMap<>();
+
+ for (PatchSetApproval a :
+ approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
+ if (a.isLegacySubmit()) {
+ continue;
+ }
+
+ Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+ if (lt.isPresent()) {
+ current.put(lt.get().getName(), a);
+ } else {
+ del.add(a);
+ }
+ }
+ return current;
+ }
+
+ private boolean insertMessage(ChangeContext ctx) {
+ String msg = Strings.nullToEmpty(in.message).trim();
+
+ StringBuilder buf = new StringBuilder();
+ for (LabelVote d : labelDelta) {
+ buf.append(" ").append(d.format());
+ }
+ if (comments.size() == 1) {
+ buf.append("\n\n(1 comment)");
+ } else if (comments.size() > 1) {
+ buf.append(String.format("\n\n(%d comments)", comments.size()));
+ }
+ if (!msg.isEmpty()) {
+ // Message was already validated when validating comments, since validators need to see
+ // everything in a single call.
+ buf.append("\n\n").append(msg);
+ } else if (in.ready) {
+ buf.append("\n\n" + START_REVIEW_MESSAGE);
+ }
+
+ List<String> pluginMessages = new ArrayList<>();
+ onPostReviews.runEach(
+ onPostReview ->
+ onPostReview
+ .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+ .ifPresent(
+ pluginMessage ->
+ pluginMessages.add(
+ !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
+ if (!pluginMessages.isEmpty()) {
+ buf.append("\n\n");
+ buf.append(Joiner.on("\n").join(pluginMessages));
+ }
+
+ if (buf.length() == 0) {
+ return false;
+ }
+
+ mailMessage =
+ cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
+ return true;
+ }
+
+ private void addLabelDelta(String name, short value) {
+ labelDelta.add(LabelVote.create(name, value));
+ }
+
+ private TraceContext.TraceTimer newTimer(String method) {
+ return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 6013862..4704a89 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -56,7 +56,7 @@
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.CommitInfo;
import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
import com.google.inject.Inject;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Repository;
@@ -952,13 +952,13 @@
// TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
StagedChange sc = stageWipChange();
ReviewInput in =
- ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+ ReviewInput.noScore().message(PostReviewOp.START_REVIEW_MESSAGE).setWorkInProgress(false);
gApi.changes().id(sc.changeId).current().review(in);
Truth.assertThat(sender.getMessages()).isNotEmpty();
String body = sender.getMessages().get(0).body();
- int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+ int idx = body.indexOf(PostReviewOp.START_REVIEW_MESSAGE);
Truth.assertThat(idx).isAtLeast(0);
- Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+ Truth.assertThat(body.indexOf(PostReviewOp.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
}
private void review(TestAccount account, String changeId, EmailStrategy strategy)
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 08e2e66..6788aa3 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -334,6 +334,10 @@
lineNum: LineNumber;
}
+export declare interface RenderProgressEventDetail {
+ linesRendered: number;
+}
+
export declare interface DisplayLine {
side: Side;
lineNum: LineNumber;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 119b905..a4ef0c3 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -65,7 +65,8 @@
// private but used in test
@state() topic?: string;
- @state() private baseChange?: ChangeId;
+ @property({type: String})
+ baseChange?: ChangeId;
@state() private baseCommit?: string;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
index 6d0db71..7b14612 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -11,7 +11,6 @@
import {pluralize} from '../../../utils/string-util';
import {subscribe} from '../../lit/subscription-controller';
import '../../shared/gr-button/gr-button';
-import '../gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
@@ -113,8 +112,6 @@
<gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
<gr-change-list-topic-flow></gr-change-list-topic-flow>
<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
- <gr-change-list-bulk-abandon-flow>
- </gr-change-list-bulk-abandon-flow>
</div>
</div>
</td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
index d267b22..bc73990 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -67,8 +67,6 @@
<gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
<gr-change-list-topic-flow></gr-change-list-topic-flow>
<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
- <gr-change-list-bulk-abandon-flow>
- </gr-change-list-bulk-abandon-flow>
</div>
</div>
</td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index a2d8ea5..2701e91 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -42,6 +42,8 @@
private readonly userModel = getAppContext().userModel;
+ private readonly reportingService = getAppContext().reportingService;
+
@state() selectedChanges: ChangeInfo[] = [];
@state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
@@ -195,8 +197,12 @@
fireReload(this, true);
}
- private handleConfirm() {
+ private async handleConfirm() {
this.progressByChange.clear();
+ this.reportingService.reportInteraction('bulk-action', {
+ type: 'vote',
+ selectedChangeCount: this.selectedChanges.length,
+ });
const reviewInput: ReviewInput = {
labels: this.getLabelValues(
this.computeCommonPermittedLabels(this.computePermittedLabels())
@@ -207,6 +213,7 @@
}
this.requestUpdate();
const promises = this.getBulkActionsModel().voteChanges(reviewInput);
+
for (let index = 0; index < promises.length; index++) {
const changeNum = this.selectedChanges[index]._number;
promises[index]
@@ -227,6 +234,34 @@
}
});
}
+
+ // TODO: replace with Promise.allSettled once we upgrade to ES2020 or higher
+ // The names and types here match Promise.allSettled.
+ await Promise.all(
+ promises.map(promise =>
+ promise
+ .then(value => {
+ return {
+ status: 'fulfilled',
+ value,
+ };
+ })
+ .catch(reason => {
+ return {
+ status: 'rejected',
+ reason,
+ };
+ })
+ )
+ );
+ if (getOverallStatus(this.progressByChange) === ProgressStatus.FAILED) {
+ this.reportingService.reportInteraction('bulk-action-failure', {
+ type: 'vote',
+ count: Array.from(this.progressByChange.values()).filter(
+ status => status === ProgressStatus.FAILED
+ ).length,
+ });
+ }
}
// private but used in tests
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 1dd737d..ab2b4b9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -18,6 +18,7 @@
query,
mockPromise,
queryAll,
+ stubReporting,
} from '../../../test/test-utils';
import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
import {getAppContext} from '../../../services/app-context';
@@ -34,6 +35,7 @@
import {GrButton} from '../../shared/gr-button/gr-button';
import {ProgressStatus} from '../../../constants/constants';
import {StandardLabels} from '../../../utils/label-util';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
const change1: ChangeInfo = {
...createChange(),
@@ -96,6 +98,7 @@
let getChangesStub: SinonStubbedMember<
RestApiService['getDetailedChangesWithActions']
>;
+ let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
async function selectChange(change: ChangeInfo) {
model.addSelectedChangeNum(change._number);
@@ -108,6 +111,7 @@
setup(async () => {
model = new BulkActionsModel(getAppContext().restApiService);
getChangesStub = stubRestApi('getDetailedChangesWithActions');
+ reportingStub = stubReporting('reportInteraction');
element = (
await fixture(
wrapInProvider(
@@ -256,6 +260,11 @@
queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
);
+ assert.deepEqual(reportingStub.lastCall.args[1], {
+ type: 'vote',
+ selectedChangeCount: 1,
+ });
+
assert.equal(
element.progressByChange.get(1 as NumericChangeId),
ProgressStatus.RUNNING
@@ -321,6 +330,14 @@
// Dialog does not autoclose and fire reload event if some request fails
assert.isFalse(dispatchEventStub.called);
+ assert.deepEqual(reportingStub.lastCall.args, [
+ 'bulk-action-failure',
+ {
+ type: 'vote',
+ count: 2,
+ },
+ ]);
+
queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
await waitUntil(() => dispatchEventStub.called);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index bb7b6ec..8b12789 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -22,6 +22,7 @@
} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import '../../shared/gr-account-list/gr-account-list';
import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {AccountInputDetail} from '../../shared/gr-account-list/gr-account-list';
const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE: Record<
ReviewerState,
@@ -40,7 +41,10 @@
@state() private updatedAccountsByReviewerState: Map<
ReviewerState,
AccountInfo[]
- > = new Map();
+ > = new Map([
+ [ReviewerState.REVIEWER, []],
+ [ReviewerState.CC, []],
+ ]);
@state() private suggestionsProviderByReviewerState: Map<
ReviewerState,
@@ -56,6 +60,8 @@
@query('gr-overlay') private overlay!: GrOverlay;
+ private readonly reportingService = getAppContext().reportingService;
+
private getBulkActionsModel = resolve(this, bulkActionsModelToken);
private restApiService = getAppContext().restApiService;
@@ -147,6 +153,8 @@
.removableValues=${[]}
.suggestionsProvider=${suggestionsProvider}
.placeholder=${placeholder}
+ @account-added=${(e: CustomEvent<AccountInputDetail>) =>
+ this.onAccountAdded(reviewerState, e)}
>
</gr-account-list>
`;
@@ -185,6 +193,28 @@
this.requestUpdate();
}
+ /* Removes accounts from one list when they are added to the other */
+ private onAccountAdded(
+ reviewerState: ReviewerState,
+ event: CustomEvent<AccountInputDetail>
+ ) {
+ const account = event.detail.account as AccountInfo;
+ const oppositeReviewerState =
+ reviewerState === ReviewerState.CC
+ ? ReviewerState.REVIEWER
+ : ReviewerState.CC;
+ const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
+ oppositeReviewerState
+ )!;
+ const oppositeUpdatedAccountIndex = oppositeUpdatedAccounts.findIndex(
+ acc => acc._account_id === account._account_id
+ );
+ if (oppositeUpdatedAccountIndex >= 0) {
+ oppositeUpdatedAccounts.splice(oppositeUpdatedAccountIndex, 1);
+ this.requestUpdate();
+ }
+ }
+
private onConfirm(overallStatus: ProgressStatus) {
switch (overallStatus) {
case ProgressStatus.NOT_STARTED:
@@ -199,7 +229,11 @@
}
}
- private saveReviewers() {
+ private async saveReviewers() {
+ this.reportingService.reportInteraction('bulk-action', {
+ type: 'add-reviewer',
+ selectedChangeCount: this.selectedChanges.length,
+ });
this.progressByChangeNum = new Map(
this.selectedChanges.map(change => [
change._number,
@@ -224,6 +258,34 @@
this.requestUpdate();
});
}
+
+ // TODO: replace with Promise.allSettled once we upgrade to ES2020 or higher
+ // The names and types here match Promise.allSettled.
+ await Promise.all(
+ inFlightActions.map(promise =>
+ promise
+ .then(value => {
+ return {
+ status: 'fulfilled',
+ value,
+ };
+ })
+ .catch(reason => {
+ return {
+ status: 'rejected',
+ reason,
+ };
+ })
+ )
+ );
+ if (getOverallStatus(this.progressByChangeNum) === ProgressStatus.FAILED) {
+ this.reportingService.reportInteraction('bulk-action-failure', {
+ type: 'add-reviewer',
+ count: Array.from(this.progressByChangeNum.values()).filter(
+ status => status === ProgressStatus.FAILED
+ ).length,
+ });
+ }
}
private isFlowDisabled() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index afc7b4b..4cd76f4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {fixture, html} from '@open-wc/testing-helpers';
+import {SinonStubbedMember} from 'sinon';
import {AccountInfo, ReviewerState} from '../../../api/rest-api';
import {
BulkActionsModel,
@@ -11,6 +12,7 @@
} from '../../../models/bulk-actions/bulk-actions-model';
import {wrapInProvider} from '../../../models/di-provider-element';
import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import '../../../test/common-test-setup-karma';
import {
createAccountWithIdNameAndEmail,
@@ -20,10 +22,12 @@
MockPromise,
mockPromise,
queryAndAssert,
+ stubReporting,
stubRestApi,
waitUntilObserved,
} from '../../../test/test-utils';
import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {ValueChangedEvent} from '../../../types/events';
import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
@@ -60,6 +64,7 @@
suite('gr-change-list-reviewer-flow tests', () => {
let element: GrChangeListReviewerFlow;
let model: BulkActionsModel;
+ let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
async function selectChange(change: ChangeInfo) {
model.addSelectedChangeNum(change._number);
@@ -71,6 +76,7 @@
setup(async () => {
stubRestApi('getDetailedChangesWithActions').resolves(changes);
+ reportingStub = stubReporting('reportInteraction');
model = new BulkActionsModel(getAppContext().restApiService);
model.sync(changes);
@@ -223,6 +229,11 @@
dialog.confirmButton!.click();
await element.updateComplete;
+ assert.deepEqual(reportingStub.lastCall.args[1], {
+ type: 'add-reviewer',
+ selectedChangeCount: 2,
+ });
+
assert.isTrue(saveChangeReviewStub.calledTwice);
assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
changes[0]._number,
@@ -246,6 +257,58 @@
]);
});
+ test('removes from reviewer list when added to cc', async () => {
+ const ccList = queryAndAssert<GrAccountList>(
+ dialog,
+ 'gr-account-list#cc-list'
+ );
+ const reviewerList = queryAndAssert<GrAccountList>(
+ dialog,
+ 'gr-account-list#reviewer-list'
+ );
+ assert.sameOrderedMembers(reviewerList.accounts, [accounts[0]]);
+
+ ccList.handleAdd(
+ new CustomEvent('add', {
+ detail: {
+ value: {
+ account: accounts[0],
+ count: 1,
+ },
+ },
+ }) as unknown as ValueChangedEvent<string>
+ );
+ await flush();
+
+ assert.isEmpty(reviewerList.accounts);
+ });
+
+ test('removes from cc list when added to reviewer', async () => {
+ const ccList = queryAndAssert<GrAccountList>(
+ dialog,
+ 'gr-account-list#cc-list'
+ );
+ const reviewerList = queryAndAssert<GrAccountList>(
+ dialog,
+ 'gr-account-list#reviewer-list'
+ );
+ assert.sameOrderedMembers(ccList.accounts, [accounts[3]]);
+
+ reviewerList.handleAdd(
+ new CustomEvent('add', {
+ detail: {
+ value: {
+ account: accounts[3],
+ count: 1,
+ },
+ },
+ }) as unknown as ValueChangedEvent<string>
+ );
+ await flush();
+
+ assert.isEmpty(ccList.accounts);
+ });
+
test('confirm button text updates', async () => {
assert.equal(dialog.confirmLabel, 'Add');
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 5f6732e..2946b3a 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
@@ -29,7 +29,6 @@
import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
import '../../../styles/shared-styles';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {htmlTemplate} from './gr-change-actions_html';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {getAppContext} from '../../../services/app-context';
@@ -47,7 +46,6 @@
NotifyType,
} from '../../../constants/constants';
import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
import {
AccountInfo,
ActionInfo,
@@ -63,11 +61,9 @@
LabelInfo,
NumericChangeId,
PatchSetNum,
- PropertyType,
RequestPayload,
RevertSubmissionInfo,
ReviewInput,
- ServerInfo,
} from '../../../types/common';
import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -86,13 +82,17 @@
ConfirmRebaseEventDetail,
GrConfirmRebaseDialog,
} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {GrButton} from '../../shared/gr-button/gr-button';
import {
GrChangeActionsElement,
UIActionInfo,
} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {
+ fire,
+ fireAlert,
+ fireEvent,
+ fireReload,
+} from '../../../utils/event-util';
import {
getApprovalInfo,
getVotingRange,
@@ -108,8 +108,13 @@
} from '../../../api/change-actions';
import {ErrorCallback} from '../../../api/rest';
import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
import {changeModelToken} from '../../../models/change/change-model';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {ifDefined} from 'lit/directives/if-defined';
+import {assertIsDefined, queryAll} from '../../../utils/common-util';
const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -320,35 +325,11 @@
init?(): void;
}
-export interface GrChangeActions {
- $: {
- mainContent: Element;
- overlay: GrOverlay;
- confirmRebase: GrConfirmRebaseDialog;
- confirmCherrypick: GrConfirmCherrypickDialog;
- confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
- confirmMove: GrConfirmMoveDialog;
- confirmRevertDialog: GrConfirmRevertDialog;
- confirmAbandonDialog: GrConfirmAbandonDialog;
- confirmSubmitDialog: GrConfirmSubmitDialog;
- createFollowUpDialog: GrDialog;
- createFollowUpChange: GrCreateChangeDialog;
- confirmDeleteDialog: GrDialog;
- confirmDeleteEditDialog: GrDialog;
- moreActions: GrDropdown;
- secondaryActions: HTMLElement;
- };
-}
-
@customElement('gr-change-actions')
export class GrChangeActions
- extends DIPolymerElement
+ extends LitElement
implements GrChangeActionsElement
{
- static get template() {
- return htmlTemplate;
- }
-
/**
* Fired when the change should be reloaded.
*
@@ -373,6 +354,37 @@
* @event show-error
*/
+ @query('#mainContent') mainContent?: Element;
+
+ @query('#overlay') overlay?: GrOverlay;
+
+ @query('#confirmRebase') confirmRebase?: GrConfirmRebaseDialog;
+
+ @query('#confirmCherrypick') confirmCherrypick?: GrConfirmCherrypickDialog;
+
+ @query('#confirmCherrypickConflict')
+ confirmCherrypickConflict?: GrConfirmCherrypickConflictDialog;
+
+ @query('#confirmMove') confirmMove?: GrConfirmMoveDialog;
+
+ @query('#confirmRevertDialog') confirmRevertDialog?: GrConfirmRevertDialog;
+
+ @query('#confirmAbandonDialog') confirmAbandonDialog?: GrConfirmAbandonDialog;
+
+ @query('#confirmSubmitDialog') confirmSubmitDialog?: GrConfirmSubmitDialog;
+
+ @query('#createFollowUpDialog') createFollowUpDialog?: GrDialog;
+
+ @query('#createFollowUpChange') createFollowUpChange?: GrCreateChangeDialog;
+
+ @query('#confirmDeleteDialog') confirmDeleteDialog?: GrDialog;
+
+ @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog;
+
+ @query('#moreActions') moreActions?: GrDropdown;
+
+ @query('#secondaryActions') secondaryActions?: HTMLElement;
+
// TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
// properties are replaced with enums everywhere and remove them from
// the GrChangeActions class
@@ -407,8 +419,8 @@
@property({type: Boolean})
_hasKnownChainState = false;
- @property({type: Boolean})
- _hideQuickApproveAction = false;
+ // private but used in test
+ @state() _hideQuickApproveAction = false;
@property({type: Object})
account?: AccountInfo;
@@ -422,7 +434,7 @@
@property({type: String})
commitNum?: CommitId;
- @property({type: Boolean, observer: '_computeChainState'})
+ @property({type: Boolean})
hasParent?: boolean;
@property({type: String})
@@ -431,58 +443,39 @@
@property({type: String})
commitMessage = '';
- @property({type: Object, notify: true})
+ @property({type: Object})
revisionActions: ActionNameToActionInfoMap = {};
- @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
- _revisionSubmitAction?: ActionInfo | null;
+ @state() private revisionSubmitAction?: ActionInfo | null;
- @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
- _revisionRebaseAction?: ActionInfo | null;
+ // used as a proprty type so cannot be private
+ @state() revisionRebaseAction?: ActionInfo | null;
@property({type: String})
privateByDefault?: InheritedBooleanInfo;
- @property({type: Boolean})
- _loading = true;
+ // private but used in test
+ @state() loading = true;
- @property({type: String})
- _actionLoadingMessage = '';
+ // private but used in test
+ @state() actionLoadingMessage = '';
- @property({
- type: Array,
- computed:
- '_computeAllActions(actions.*, revisionActions.*,' +
- 'primaryActionKeys.*, _additionalActions.*, change, ' +
- '_actionPriorityOverrides.*)',
- })
- _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+ // _computeAllActions always returns an array
+ // private but used in test
+ @state() allActionValues: UIActionInfo[] = [];
- @property({
- type: Array,
- computed:
- '_computeTopLevelActions(_allActionValues.*, ' +
- '_hiddenActions.*, editMode, _overflowActions.*)',
- observer: '_filterPrimaryActions',
- })
- _topLevelActions?: UIActionInfo[];
+ // private but used in test
+ @state() topLevelActions?: UIActionInfo[];
- @property({type: Array})
- _topLevelPrimaryActions?: UIActionInfo[];
+ // private but used in test
+ @state() topLevelPrimaryActions?: UIActionInfo[];
- @property({type: Array})
- _topLevelSecondaryActions?: UIActionInfo[];
+ // private but used in test
+ @state() topLevelSecondaryActions?: UIActionInfo[];
- @property({
- type: Array,
- computed:
- '_computeMenuActions(_allActionValues.*, ' +
- '_hiddenActions.*, _overflowActions.*)',
- })
- _menuActions?: MenuAction[];
+ @state() private menuActions?: MenuAction[];
- @property({type: Array})
- _overflowActions: OverflowAction[] = [
+ @state() private overflowActions: OverflowAction[] = [
{
type: ActionType.CHANGE,
key: ChangeActions.WIP,
@@ -529,17 +522,15 @@
},
];
- @property({type: Array})
- _actionPriorityOverrides: ActionPriorityOverride[] = [];
+ @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
- @property({type: Array})
- _additionalActions: UIActionInfo[] = [];
+ @state() private additionalActions: UIActionInfo[] = [];
- @property({type: Array})
- _hiddenActions: string[] = [];
+ // private but used in test
+ @state() hiddenActions: string[] = [];
- @property({type: Array})
- _disabledMenuActions: string[] = [];
+ // private but used in test
+ @state() disabledMenuActions: string[] = [];
@property({type: Boolean})
editPatchsetLoaded = false;
@@ -550,9 +541,6 @@
@property({type: Boolean})
editBasedOnCurrentPatchSet = true;
- @property({type: Object})
- _config?: ServerInfo;
-
@property({type: Boolean})
loggedIn = false;
@@ -563,31 +551,385 @@
constructor() {
super();
this.addEventListener('fullscreen-overlay-opened', () =>
- this._handleHideBackgroundContent()
+ this.handleHideBackgroundContent()
);
this.addEventListener('fullscreen-overlay-closed', () =>
- this._handleShowBackgroundContent()
+ this.handleShowBackgroundContent()
);
}
- override ready() {
- super.ready();
+ override connectedCallback() {
+ super.connectedCallback();
this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
- this.restApiService.getConfig().then(config => {
- this._config = config;
- });
- this._handleLoadingComplete();
+ this.handleLoadingComplete();
}
- _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
- return this._getRevisionAction(revisionActions, 'submit');
+ static override get styles() {
+ return [
+ sharedStyles,
+ css`
+ :host {
+ display: flex;
+ font-family: var(--font-family);
+ }
+ #actionLoadingMessage,
+ #mainContent,
+ section {
+ display: flex;
+ }
+ #actionLoadingMessage,
+ gr-button,
+ gr-dropdown {
+ /* px because don't have the same font size */
+ margin-left: 8px;
+ }
+ gr-button {
+ display: block;
+ }
+ #actionLoadingMessage {
+ align-items: center;
+ color: var(--deemphasized-text-color);
+ }
+ #confirmSubmitDialog .changeSubject {
+ margin: var(--spacing-l);
+ text-align: center;
+ }
+ iron-icon {
+ color: inherit;
+ margin-right: var(--spacing-xs);
+ }
+ #moreActions iron-icon {
+ margin: 0;
+ }
+ #moreMessage,
+ .hidden {
+ display: none;
+ }
+ @media screen and (max-width: 50em) {
+ #mainContent {
+ flex-wrap: wrap;
+ }
+ gr-button {
+ --gr-button-padding: var(--spacing-m);
+ white-space: nowrap;
+ }
+ gr-button,
+ gr-dropdown {
+ margin: 0;
+ }
+ #actionLoadingMessage {
+ margin: var(--spacing-m);
+ text-align: center;
+ }
+ #moreMessage {
+ display: inline;
+ }
+ }
+ `,
+ ];
}
- _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
- return this._getRevisionAction(revisionActions, 'rebase');
+ override render() {
+ if (!this.change) return nothing;
+ return html`
+ <div id="mainContent">
+ <span id="actionLoadingMessage" ?hidden=${!this.actionLoadingMessage}>
+ ${this.actionLoadingMessage}
+ </span>
+ <section
+ id="primaryActions"
+ ?hidden=${this.loading ||
+ !this.topLevelActions ||
+ !this.topLevelActions.length}
+ >
+ ${this.topLevelPrimaryActions?.map(action =>
+ this.renderTopPrimaryActions(action)
+ )}
+ </section>
+ <section
+ id="secondaryActions"
+ ?hidden=${this.loading ||
+ !this.topLevelActions ||
+ !this.topLevelActions.length}
+ >
+ ${this.topLevelSecondaryActions?.map(action =>
+ this.renderTopSecondaryActions(action)
+ )}
+ </section>
+ <gr-button ?hidden=${!this.loading}>Loading actions...</gr-button>
+ <gr-dropdown
+ id="moreActions"
+ link
+ .verticalOffset=${32}
+ .horizontalAlign=${'right'}
+ @tap-item=${this.handleOverflowItemTap}
+ ?hidden=${this.loading ||
+ !this.menuActions ||
+ !this.menuActions.length}
+ .disabledIds=${this.disabledMenuActions}
+ .items=${this.menuActions}
+ >
+ <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+ </iron-icon>
+ <span id="moreMessage">More</span>
+ </gr-dropdown>
+ </div>
+ <gr-overlay id="overlay" with-backdrop="">
+ <gr-confirm-rebase-dialog
+ id="confirmRebase"
+ class="confirmDialog"
+ .changeNumber=${this.change?._number}
+ @confirm=${this.handleRebaseConfirm}
+ @cancel=${this.handleConfirmDialogCancel}
+ .branch=${this.change?.branch}
+ .hasParent=${this.hasParent}
+ .rebaseOnCurrent=${this.revisionRebaseAction
+ ? !!this.revisionRebaseAction.enabled
+ : null}
+ ></gr-confirm-rebase-dialog>
+ <gr-confirm-cherrypick-dialog
+ id="confirmCherrypick"
+ class="confirmDialog"
+ .changeStatus=${this.changeStatus}
+ .commitMessage=${this.commitMessage}
+ .commitNum=${this.commitNum}
+ @confirm=${this.handleCherrypickConfirm}
+ @cancel=${this.handleConfirmDialogCancel}
+ .project=${this.change?.project}
+ ></gr-confirm-cherrypick-dialog>
+ <gr-confirm-cherrypick-conflict-dialog
+ id="confirmCherrypickConflict"
+ class="confirmDialog"
+ @confirm=${this.handleCherrypickConflictConfirm}
+ @cancel=${this.handleConfirmDialogCancel}
+ ></gr-confirm-cherrypick-conflict-dialog>
+ <gr-confirm-move-dialog
+ id="confirmMove"
+ class="confirmDialog"
+ @confirm=${this.handleMoveConfirm}
+ @cancel=${this.handleConfirmDialogCancel}
+ .project=${this.change?.project}
+ ></gr-confirm-move-dialog>
+ <gr-confirm-revert-dialog
+ id="confirmRevertDialog"
+ class="confirmDialog"
+ @confirm=${this.handleRevertDialogConfirm}
+ @cancel=${this.handleConfirmDialogCancel}
+ ></gr-confirm-revert-dialog>
+ <gr-confirm-abandon-dialog
+ id="confirmAbandonDialog"
+ class="confirmDialog"
+ @confirm=${this.handleAbandonDialogConfirm}
+ @cancel=${this.handleConfirmDialogCancel}
+ ></gr-confirm-abandon-dialog>
+ <gr-confirm-submit-dialog
+ id="confirmSubmitDialog"
+ class="confirmDialog"
+ .action=${this.revisionSubmitAction}
+ @cancel=${this.handleConfirmDialogCancel}
+ @confirm=${this.handleSubmitConfirm}
+ ></gr-confirm-submit-dialog>
+ <gr-dialog
+ id="createFollowUpDialog"
+ class="confirmDialog"
+ confirm-label="Create"
+ @confirm=${this.handleCreateFollowUpChange}
+ @cancel=${this.handleCloseCreateFollowUpChange}
+ >
+ <div class="header" slot="header">Create Follow-Up Change</div>
+ <div class="main" slot="main">
+ <gr-create-change-dialog
+ id="createFollowUpChange"
+ .branch=${this.change?.branch}
+ .baseChange=${this.change?.id}
+ .repoName=${this.change?.project}
+ .privateByDefault=${this.privateByDefault}
+ ></gr-create-change-dialog>
+ </div>
+ </gr-dialog>
+ <gr-dialog
+ id="confirmDeleteDialog"
+ class="confirmDialog"
+ confirm-label="Delete"
+ confirm-on-enter=""
+ @cancel=${this.handleConfirmDialogCancel}
+ @confirm=${this.handleDeleteConfirm}
+ >
+ <div class="header" slot="header">Delete Change</div>
+ <div class="main" slot="main">
+ Do you really want to delete the change?
+ </div>
+ </gr-dialog>
+ <gr-dialog
+ id="confirmDeleteEditDialog"
+ class="confirmDialog"
+ confirm-label="Delete"
+ confirm-on-enter=""
+ @cancel=${this.handleConfirmDialogCancel}
+ @confirm=${this.handleDeleteEditConfirm}
+ >
+ <div class="header" slot="header">Delete Change Edit</div>
+ <div class="main" slot="main">
+ Do you really want to delete the edit?
+ </div>
+ </gr-dialog>
+ </gr-overlay>
+ `;
}
- _getRevisionAction(
+ private renderTopPrimaryActions(action: UIActionInfo) {
+ return html`
+ <gr-tooltip-content
+ title=${ifDefined(action.title)}
+ .hasTooltip=${!!action.title}
+ ?position-below=${true}
+ >
+ <gr-button
+ link
+ class=${action.__key}
+ data-action-key=${action.__key}
+ data-label=${action.label}
+ ?disabled=${this.calculateDisabled(action)}
+ @click=${(e: MouseEvent) =>
+ this.handleActionTap(e, action.__key, action.__type)}
+ >
+ <iron-icon
+ class=${action.icon ? '' : 'hidden'}
+ .icon="gr-icons:${action.icon}"
+ ></iron-icon>
+ ${action.label}
+ </gr-button>
+ </gr-tooltip-content>
+ `;
+ }
+
+ private renderTopSecondaryActions(action: UIActionInfo) {
+ return html`
+ <gr-tooltip-content
+ title=${ifDefined(action.title)}
+ .hasTooltip=${!!action.title}
+ ?position-below=${true}
+ >
+ <gr-button
+ link
+ class=${action.__key}
+ data-action-key=${action.__key}
+ data-label=${action.label}
+ ?disabled=${this.calculateDisabled(action)}
+ @click=${(e: MouseEvent) =>
+ this.handleActionTap(e, action.__key, action.__type)}
+ >
+ <iron-icon
+ class=${action.icon ? '' : 'hidden'}
+ icon="gr-icons:${action.icon}"
+ ></iron-icon>
+ ${action.label}
+ </gr-button>
+ </gr-tooltip-content>
+ `;
+ }
+
+ override willUpdate(changedProperties: PropertyValues) {
+ if (changedProperties.has('hasParent')) {
+ this.computeChainState();
+ }
+
+ if (changedProperties.has('revisionActions')) {
+ this.revisionSubmitAction = this.getSubmitAction(this.revisionActions);
+ this.revisionRebaseAction = this.getRebaseAction(this.revisionActions);
+ }
+
+ if (
+ changedProperties.has('actions') ||
+ changedProperties.has('revisionActions') ||
+ changedProperties.has('primaryActionKeys') ||
+ changedProperties.has('additionalActions') ||
+ changedProperties.has('change') ||
+ changedProperties.has('actionPriorityOverrides')
+ ) {
+ this.allActionValues = this.computeAllActions(
+ this.actions,
+ this.revisionActions,
+ this.primaryActionKeys,
+ this.additionalActions,
+ this.change
+ );
+ }
+
+ if (
+ changedProperties.has('allActionValues') ||
+ changedProperties.has('hiddenActions') ||
+ changedProperties.has('editMode') ||
+ changedProperties.has('overflowActions')
+ ) {
+ this.topLevelActions = this.computeTopLevelActions(
+ this.allActionValues,
+ this.hiddenActions,
+ this.editMode
+ );
+ }
+
+ if (changedProperties.has('topLevelActions')) {
+ this.filterPrimaryActions(this.topLevelActions);
+ }
+
+ if (
+ changedProperties.has('allActionValues') ||
+ changedProperties.has('hiddenActions') ||
+ changedProperties.has('overflowActions')
+ ) {
+ this.menuActions = this.computeMenuActions(
+ this.allActionValues,
+ this.hiddenActions
+ );
+ }
+
+ if (changedProperties.has('change')) {
+ this.reload();
+ }
+
+ if (
+ changedProperties.has('actions') ||
+ changedProperties.has('revisionActions') ||
+ changedProperties.has('additionalActions')
+ ) {
+ this.actionsChanged(
+ this.actions,
+ this.revisionActions,
+ this.additionalActions
+ );
+ }
+
+ if (
+ changedProperties.has('editMode') ||
+ changedProperties.has('editPatchsetLoaded') ||
+ changedProperties.has('editBasedOnCurrentPatchSet') ||
+ changedProperties.has('disableEdit') ||
+ changedProperties.has('loggedIn') ||
+ changedProperties.has('actions') ||
+ changedProperties.has('change')
+ ) {
+ this.editStatusChanged(
+ this.editMode,
+ this.editPatchsetLoaded,
+ this.editBasedOnCurrentPatchSet,
+ this.disableEdit,
+ this.loggedIn,
+ this.actions,
+ this.change
+ );
+ }
+ }
+
+ private getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+ return this.getRevisionAction(revisionActions, 'submit');
+ }
+
+ private getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+ return this.getRevisionAction(revisionActions, 'rebase');
+ }
+
+ private getRevisionAction(
revisionActions: ActionNameToActionInfoMap,
actionName: string
) {
@@ -608,7 +950,7 @@
}
const change = this.change;
- this._loading = true;
+ this.loading = true;
return this.restApiService
.getChangeRevisionActions(this.changeNum, this.latestPatchNum)
.then(revisionActions => {
@@ -617,37 +959,33 @@
}
this.revisionActions = revisionActions;
- this._sendShowRevisionActions({
+ this.sendShowRevisionActions({
change,
revisionActions,
});
- this._handleLoadingComplete();
+ this.handleLoadingComplete();
})
.catch(err => {
fireAlert(this, ERR_REVISION_ACTIONS);
- this._loading = false;
+ this.loading = false;
throw err;
});
}
- _handleLoadingComplete() {
+ private handleLoadingComplete() {
getPluginLoader()
.awaitPluginsLoaded()
- .then(() => (this._loading = false));
+ .then(() => (this.loading = false));
}
- _sendShowRevisionActions(detail: {
+ // private but used in test
+ sendShowRevisionActions(detail: {
change: ChangeInfo;
revisionActions: ActionNameToActionInfoMap;
}) {
this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
}
- @observe('change')
- _changeChanged() {
- this.reload();
- }
-
addActionButton(type: ActionType, label: string) {
if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
throw Error(`Invalid action type: ${type}`);
@@ -659,16 +997,18 @@
__key:
ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
};
- this.push('_additionalActions', action);
+ this.additionalActions.push(action);
+ this.requestUpdate('additionalActions');
return action.__key;
}
removeActionButton(key: string) {
- const idx = this._indexOfActionButtonWithKey(key);
+ const idx = this.indexOfActionButtonWithKey(key);
if (idx === -1) {
return;
}
- this.splice('_additionalActions', idx, 1);
+ this.additionalActions.splice(idx, 1);
+ this.requestUpdate('additionalActions');
}
setActionButtonProp<T extends keyof UIActionInfo>(
@@ -676,26 +1016,26 @@
prop: T,
value: UIActionInfo[T]
) {
- this.set(
- ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
- value
- );
+ this.additionalActions[this.indexOfActionButtonWithKey(key)][prop] = value;
+ this.requestUpdate('additionalActions');
}
setActionOverflow(type: ActionType, key: string, overflow: boolean) {
if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
throw Error(`Invalid action type given: ${type}`);
}
- const index = this._getActionOverflowIndex(type, key);
+ const index = this.getActionOverflowIndex(type, key);
const action: OverflowAction = {
type,
key,
overflow,
};
if (!overflow && index !== -1) {
- this.splice('_overflowActions', index, 1);
+ this.overflowActions.splice(index, 1);
+ this.requestUpdate('overflowActions');
} else if (overflow) {
- this.push('_overflowActions', action);
+ this.overflowActions.push(action);
+ this.requestUpdate('overflowActions');
}
}
@@ -707,7 +1047,7 @@
if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
throw Error(`Invalid action type given: ${type}`);
}
- const index = this._actionPriorityOverrides.findIndex(
+ const index = this.actionPriorityOverrides.findIndex(
action => action.type === type && action.key === key
);
const action: ActionPriorityOverride = {
@@ -716,9 +1056,11 @@
priority,
};
if (index !== -1) {
- this.set('_actionPriorityOverrides', index, action);
+ this.actionPriorityOverrides[index] = action;
+ this.requestUpdate('actionPriorityOverrides');
} else {
- this.push('_actionPriorityOverrides', action);
+ this.actionPriorityOverrides.push(action);
+ this.requestUpdate('actionPriorityOverrides');
}
}
@@ -731,11 +1073,13 @@
throw Error(`Invalid action type given: ${type}`);
}
- const idx = this._hiddenActions.indexOf(key);
+ const idx = this.hiddenActions.indexOf(key);
if (hidden && idx === -1) {
- this.push('_hiddenActions', key);
+ this.hiddenActions.push(key);
+ this.requestUpdate('hiddenActions');
} else if (!hidden && idx !== -1) {
- this.splice('_hiddenActions', idx, 1);
+ this.hiddenActions.splice(idx, 1);
+ this.requestUpdate('hiddenActions');
}
}
@@ -749,182 +1093,165 @@
}
}
- _indexOfActionButtonWithKey(key: string) {
- for (let i = 0; i < this._additionalActions.length; i++) {
- if (this._additionalActions[i].__key === key) {
+ private indexOfActionButtonWithKey(key: string) {
+ for (let i = 0; i < this.additionalActions.length; i++) {
+ if (this.additionalActions[i].__key === key) {
return i;
}
}
return -1;
}
- _shouldHideActions(
- actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
- loading?: boolean
- ) {
- return loading || !actions || !actions.base || !actions.base.length;
- }
-
- _keyCount(
- changeRecord?: PolymerDeepPropertyChange<
- ActionNameToActionInfoMap,
- ActionNameToActionInfoMap
- >
- ) {
- return Object.keys(changeRecord?.base || {}).length;
- }
-
- @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
- _actionsChanged(
- actionsChangeRecord?: PolymerDeepPropertyChange<
- ActionNameToActionInfoMap,
- ActionNameToActionInfoMap
- >,
- revisionActionsChangeRecord?: PolymerDeepPropertyChange<
- ActionNameToActionInfoMap,
- ActionNameToActionInfoMap
- >,
- additionalActionsChangeRecord?: PolymerDeepPropertyChange<
- UIActionInfo[],
- UIActionInfo[]
- >
+ private actionsChanged(
+ actionsChange?: ActionNameToActionInfoMap,
+ revisionActionsChange?: ActionNameToActionInfoMap,
+ additionalActionsChange?: UIActionInfo[]
) {
// Polymer 2: check for undefined
if (
- actionsChangeRecord === undefined ||
- revisionActionsChangeRecord === undefined ||
- additionalActionsChangeRecord === undefined
+ actionsChange === undefined ||
+ revisionActionsChange === undefined ||
+ additionalActionsChange === undefined
) {
return;
}
- const additionalActions =
- (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
- [];
this.hidden =
- this._keyCount(actionsChangeRecord) === 0 &&
- this._keyCount(revisionActionsChangeRecord) === 0 &&
- additionalActions.length === 0;
- this._actionLoadingMessage = '';
- this._actionLoadingMessage = '';
- this._disabledMenuActions = [];
+ Object.keys(actionsChange).length === 0 &&
+ Object.keys(revisionActionsChange).length === 0 &&
+ additionalActionsChange.length === 0;
+ this.actionLoadingMessage = '';
+ this.disabledMenuActions = [];
- const revisionActions = revisionActionsChangeRecord.base || {};
- if (Object.keys(revisionActions).length !== 0) {
- if (!revisionActions.download) {
- this.set('revisionActions.download', DOWNLOAD_ACTION);
+ if (Object.keys(revisionActionsChange).length !== 0) {
+ if (!revisionActionsChange.download) {
+ this.revisionActions = {
+ ...this.revisionActions,
+ download: DOWNLOAD_ACTION,
+ };
+ fire(this, 'revision-actions-changed', {
+ value: this.revisionActions,
+ });
}
}
- const actions = actionsChangeRecord.base || {};
- if (!actions.includedIn && this.change?.status === ChangeStatus.MERGED) {
- this.set('actions.includedIn', INCLUDED_IN_ACTION);
+ if (
+ !actionsChange.includedIn &&
+ this.change?.status === ChangeStatus.MERGED
+ ) {
+ this.actions = {...this.actions, includedIn: INCLUDED_IN_ACTION};
}
}
- _deleteAndNotify(actionName: string) {
+ private deleteAndNotify(actionName: string) {
if (this.actions && this.actions[actionName]) {
delete this.actions[actionName];
- // We assign a fake value of 'false' to support Polymer 2
- // see https://github.com/Polymer/polymer/issues/2631
- this.notifyPath('actions.' + actionName, false);
+ this.refreshActions();
}
}
- @observe(
- 'editMode',
- 'editPatchsetLoaded',
- 'editBasedOnCurrentPatchSet',
- 'disableEdit',
- 'loggedIn',
- 'actions.*',
- 'change.*'
- )
- _editStatusChanged(
+ // private but used in test
+ editStatusChanged(
editMode: boolean,
editPatchsetLoaded: boolean,
editBasedOnCurrentPatchSet: boolean,
disableEdit: boolean,
loggedIn: boolean,
- actionsChangeRecord?: PolymerDeepPropertyChange<
- ActionNameToActionInfoMap,
- ActionNameToActionInfoMap
- >,
- changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+ actionsChange?: ActionNameToActionInfoMap,
+ changeChange?: ChangeInfo
) {
// Hide change edits if not logged in
if (
- actionsChangeRecord === undefined ||
- changeChangeRecord === undefined ||
+ actionsChange === undefined ||
+ changeChange === undefined ||
!loggedIn
) {
return;
}
if (disableEdit) {
- this._deleteAndNotify('publishEdit');
- this._deleteAndNotify('rebaseEdit');
- this._deleteAndNotify('deleteEdit');
- this._deleteAndNotify('stopEdit');
- this._deleteAndNotify('edit');
+ this.deleteAndNotify('publishEdit');
+ this.deleteAndNotify('rebaseEdit');
+ this.deleteAndNotify('deleteEdit');
+ this.deleteAndNotify('stopEdit');
+ this.deleteAndNotify('edit');
return;
}
- const actions = actionsChangeRecord.base;
- const change = changeChangeRecord.base;
- if (actions && editPatchsetLoaded) {
+ if (actionsChange && editPatchsetLoaded) {
// Only show actions that mutate an edit if an actual edit patch set
// is loaded.
- if (changeIsOpen(change)) {
+ if (changeIsOpen(changeChange)) {
if (editBasedOnCurrentPatchSet) {
- if (!actions.publishEdit) {
- this.set('actions.publishEdit', PUBLISH_EDIT);
+ if (!actionsChange.publishEdit) {
+ this.actions = {...this.actions, publishEdit: PUBLISH_EDIT};
}
- this._deleteAndNotify('rebaseEdit');
+ this.deleteAndNotify('rebaseEdit');
} else {
- if (!actions.rebaseEdit) {
- this.set('actions.rebaseEdit', REBASE_EDIT);
+ if (!actionsChange.rebaseEdit) {
+ this.actions = {...this.actions, rebaseEdit: REBASE_EDIT};
}
- this._deleteAndNotify('publishEdit');
+ this.deleteAndNotify('publishEdit');
}
}
- if (!actions.deleteEdit) {
- this.set('actions.deleteEdit', DELETE_EDIT);
+ if (!actionsChange.deleteEdit) {
+ this.actions = {...this.actions, deleteEdit: DELETE_EDIT};
}
} else {
- this._deleteAndNotify('publishEdit');
- this._deleteAndNotify('rebaseEdit');
- this._deleteAndNotify('deleteEdit');
+ this.deleteAndNotify('publishEdit');
+ this.deleteAndNotify('rebaseEdit');
+ this.deleteAndNotify('deleteEdit');
}
- if (actions && changeIsOpen(change)) {
+ if (actionsChange && changeIsOpen(changeChange)) {
// Only show edit button if there is no edit patchset loaded and the
// file list is not in edit mode.
if (editPatchsetLoaded || editMode) {
- this._deleteAndNotify('edit');
+ this.deleteAndNotify('edit');
} else {
- if (!actions.edit) {
- this.set('actions.edit', EDIT);
+ if (!actionsChange.edit) {
+ this.actions = {...this.actions, edit: EDIT};
}
}
// Only show STOP_EDIT if edit mode is enabled, but no edit patch set
// is loaded.
if (editMode && !editPatchsetLoaded) {
- if (!actions.stopEdit) {
- this.set('actions.stopEdit', STOP_EDIT);
+ if (!actionsChange.stopEdit) {
+ this.actions = {...this.actions, stopEdit: STOP_EDIT};
fireAlert(this, 'Change is in edit mode');
}
} else {
- this._deleteAndNotify('stopEdit');
+ this.deleteAndNotify('stopEdit');
}
} else {
// Remove edit button.
- this._deleteAndNotify('edit');
+ this.deleteAndNotify('edit');
}
+
+ this.refreshActions();
}
- _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+ // It appears that trying to get life cycles to be re-triggered
+ // from a function being called by a life cycle does not work.
+ // So we have to manually do this.
+ private refreshActions() {
+ this.allActionValues = this.computeAllActions(
+ this.actions,
+ this.revisionActions,
+ this.primaryActionKeys,
+ this.additionalActions,
+ this.change
+ );
+ this.topLevelActions = this.computeTopLevelActions(
+ this.allActionValues,
+ this.hiddenActions,
+ this.editMode
+ );
+ this.filterPrimaryActions(this.topLevelActions);
+ }
+
+ private getValuesFor<T>(obj: {[key: string]: T}): T[] {
return Object.keys(obj).map(key => obj[key]);
}
- _getLabelStatus(label: LabelInfo): LabelStatus {
+ private getLabelStatus(label: LabelInfo): LabelStatus {
if (isQuickLabelInfo(label)) {
if (label.approved) {
return LabelStatus.OK;
@@ -943,7 +1270,7 @@
* Get highest score for last missing permitted label for current change.
* Returns null if no labels permitted or more than one label missing.
*/
- _getTopMissingApproval() {
+ private getTopMissingApproval() {
if (!this.change || !this.change.labels || !this.change.permitted_labels) {
return null;
}
@@ -958,7 +1285,7 @@
if (this.change.permitted_labels[label].length === 0) {
continue;
}
- const status = this._getLabelStatus(labelInfo);
+ const status = this.getLabelStatus(labelInfo);
if (status === LabelStatus.NEED) {
if (result) {
// More than one label is missing, so check if Code Review can be
@@ -1014,20 +1341,20 @@
}
hideQuickApproveAction() {
- if (!this._topLevelSecondaryActions) {
- throw new Error('_topLevelSecondaryActions must be set');
+ if (!this.topLevelSecondaryActions) {
+ throw new Error('topLevelSecondaryActions must be set');
}
- this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+ this.topLevelSecondaryActions = this.topLevelSecondaryActions.filter(
sa => !isQuickApproveAction(sa)
);
this._hideQuickApproveAction = true;
}
- _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+ private getQuickApproveAction(): QuickApproveUIActionInfo | null {
if (this._hideQuickApproveAction) {
return null;
}
- const approval = this._getTopMissingApproval();
+ const approval = this.getTopMissingApproval();
if (!approval) {
return null;
}
@@ -1049,32 +1376,23 @@
return action;
}
- _getActionValues(
- actionsChangeRecord: PolymerDeepPropertyChange<
- ActionNameToActionInfoMap,
- ActionNameToActionInfoMap
- >,
- primariesChangeRecord: PolymerDeepPropertyChange<
- PrimaryActionKey[],
- PrimaryActionKey[]
- >,
- additionalActionsChangeRecord: PolymerDeepPropertyChange<
- UIActionInfo[],
- UIActionInfo[]
- >,
+ private getActionValues(
+ actionsChange: ActionNameToActionInfoMap,
+ primariesChange: PrimaryActionKey[],
+ additionalActionsChange: UIActionInfo[],
type: ActionType
): UIActionInfo[] {
- if (!actionsChangeRecord || !primariesChangeRecord) {
+ if (!actionsChange || !primariesChange) {
return [];
}
- const actions = actionsChangeRecord.base || {};
- const primaryActionKeys = primariesChangeRecord.base || [];
+ const actions = actionsChange;
+ const primaryActionKeys = primariesChange;
const result: UIActionInfo[] = [];
const values: Array<ChangeActions | RevisionActions> =
type === ActionType.CHANGE
- ? this._getValuesFor(ChangeActions)
- : this._getValuesFor(RevisionActions);
+ ? this.getValuesFor(ChangeActions)
+ : this.getValuesFor(RevisionActions);
const pluginActions: UIActionInfo[] = [];
Object.keys(actions).forEach(a => {
@@ -1084,26 +1402,25 @@
action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
// Plugin actions always contain ~ in the key.
if (a.indexOf('~') !== -1) {
- this._populateActionUrl(action);
+ this.populateActionUrl(action);
pluginActions.push(action);
// Add server-side provided plugin actions to overflow menu.
- this._overflowActions.push({
+ this.overflowActions.push({
type,
key: a,
});
+ this.requestUpdate('overflowActions');
return;
} else if (!values.includes(a as PrimaryActionKey)) {
return;
}
- action.label = this._getActionLabel(action);
+ action.label = this.getActionLabel(action);
// Triggers a re-render by ensuring object inequality.
result.push({...action});
});
- let additionalActions =
- (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
- [];
+ let additionalActions = additionalActionsChange;
additionalActions = additionalActions
.filter(a => a.__type === type)
.map(a => {
@@ -1114,7 +1431,7 @@
return result.concat(additionalActions).concat(pluginActions);
}
- _populateActionUrl(action: UIActionInfo) {
+ private populateActionUrl(action: UIActionInfo) {
const patchNum =
action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
if (!this.changeNum) {
@@ -1129,7 +1446,7 @@
* Given a change action, return a display label that uses the appropriate
* casing or includes explanatory details.
*/
- _getActionLabel(action: UIActionInfo) {
+ private getActionLabel(action: UIActionInfo) {
if (action.label === 'Delete') {
// This label is common within change and revision actions. Make it more
// explicit to the user.
@@ -1138,34 +1455,38 @@
return 'Mark as work in progress';
}
// Otherwise, just map the name to sentence case.
- return this._toSentenceCase(action.label);
+ return this.toSentenceCase(action.label);
}
/**
* Capitalize the first letter and lowecase all others.
+ *
+ * private but used in test
*/
- _toSentenceCase(s: string) {
+ toSentenceCase(s: string) {
if (!s.length) {
return '';
}
return s[0].toUpperCase() + s.slice(1).toLowerCase();
}
- _computeLoadingLabel(action: string) {
+ private computeLoadingLabel(action: string) {
return ActionLoadingLabels[action] || 'Working...';
}
- _canSubmitChange() {
+ // private but used in test
+ canSubmitChange() {
if (!this.change) {
return false;
}
return this.jsAPI.canSubmitChange(
this.change,
- this._getRevision(this.change, this.latestPatchNum)
+ this.getRevision(this.change, this.latestPatchNum)
);
}
- _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+ // private but used in test
+ getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
for (const rev of Object.values(change.revisions)) {
if (rev._number === patchNum) {
return rev;
@@ -1187,21 +1508,23 @@
this.reporting.error(new Error('changes is undefined'));
return;
}
- this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
- this._showActionDialog(this.$.confirmRevertDialog);
+ assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+ this.confirmRevertDialog.populate(change, this.commitMessage, changes);
+ this.showActionDialog(this.confirmRevertDialog);
});
}
showSubmitDialog() {
- if (!this._canSubmitChange()) {
+ if (!this.canSubmitChange()) {
return;
}
- this._showActionDialog(this.$.confirmSubmitDialog);
+ assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+ this.showActionDialog(this.confirmSubmitDialog);
}
- _handleActionTap(e: MouseEvent) {
+ private handleActionTap(e: MouseEvent, key: string, type: string) {
e.preventDefault();
- let el = (dom(e) as EventApi).localTarget as Element;
+ let el = e.target as Element;
while (el.tagName.toLowerCase() !== 'gr-button') {
if (!el.parentElement) {
return;
@@ -1209,10 +1532,6 @@
el = el.parentElement;
}
- const key = el.getAttribute('data-action-key');
- if (!key) {
- throw new Error("Button doesn't have data-action-key attribute");
- }
if (
key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
key.indexOf('~') !== -1
@@ -1226,11 +1545,10 @@
);
return;
}
- const type = el.getAttribute('data-action-type') as ActionType;
- this._handleAction(type, key);
+ this.handleAction(type as ActionType, key);
}
- _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+ private handleOverflowItemTap(e: CustomEvent<MenuAction>) {
e.preventDefault();
const el = (dom(e) as EventApi).localTarget as Element;
const key = e.detail.action.__key;
@@ -1247,147 +1565,160 @@
);
return;
}
- this._handleAction(e.detail.action.__type, e.detail.action.__key);
+ this.handleAction(e.detail.action.__type, e.detail.action.__key);
}
- _handleAction(type: ActionType, key: string) {
+ // private but used in test
+ handleAction(type: ActionType, key: string) {
this.reporting.reportInteraction(`${type}-${key}`);
switch (type) {
case ActionType.REVISION:
- this._handleRevisionAction(key);
+ this.handleRevisionAction(key);
break;
case ActionType.CHANGE:
- this._handleChangeAction(key);
+ this.handleChangeAction(key);
break;
default:
- this._fireAction(
- this._prependSlash(key),
+ this.fireAction(
+ this.prependSlash(key),
assertUIActionInfo(this.actions[key]),
false
);
}
}
- _handleChangeAction(key: string) {
+ // private but used in test
+ handleChangeAction(key: string) {
switch (key) {
case ChangeActions.REVERT:
this.showRevertDialog();
break;
case ChangeActions.ABANDON:
- this._showActionDialog(this.$.confirmAbandonDialog);
+ assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+ this.showActionDialog(this.confirmAbandonDialog);
break;
case QUICK_APPROVE_ACTION.key: {
- const action = this._allActionValues.find(isQuickApproveAction);
+ const action = this.allActionValues.find(isQuickApproveAction);
if (!action) {
return;
}
- this._fireAction(this._prependSlash(key), action, true, action.payload);
+ this.fireAction(this.prependSlash(key), action, true, action.payload);
break;
}
case ChangeActions.EDIT:
- this._handleEditTap();
+ this.handleEditTap();
break;
case ChangeActions.STOP_EDIT:
- this._handleStopEditTap();
+ this.handleStopEditTap();
break;
case ChangeActions.DELETE:
- this._handleDeleteTap();
+ this.handleDeleteTap();
break;
case ChangeActions.DELETE_EDIT:
- this._handleDeleteEditTap();
+ this.handleDeleteEditTap();
break;
case ChangeActions.FOLLOW_UP:
- this._handleFollowUpTap();
+ this.handleFollowUpTap();
break;
case ChangeActions.WIP:
- this._handleWipTap();
+ this.handleWipTap();
break;
case ChangeActions.MOVE:
- this._handleMoveTap();
+ this.handleMoveTap();
break;
case ChangeActions.PUBLISH_EDIT:
- this._handlePublishEditTap();
+ this.handlePublishEditTap();
break;
case ChangeActions.REBASE_EDIT:
- this._handleRebaseEditTap();
+ this.handleRebaseEditTap();
break;
case ChangeActions.INCLUDED_IN:
- this._handleIncludedInTap();
+ this.handleIncludedInTap();
break;
default:
- this._fireAction(
- this._prependSlash(key),
+ this.fireAction(
+ this.prependSlash(key),
assertUIActionInfo(this.actions[key]),
false
);
}
}
- _handleRevisionAction(key: string) {
+ private handleRevisionAction(key: string) {
switch (key) {
case RevisionActions.REBASE:
- this._showActionDialog(this.$.confirmRebase);
- this.$.confirmRebase.fetchRecentChanges();
+ assertIsDefined(this.confirmRebase, 'confirmRebase');
+ this.showActionDialog(this.confirmRebase);
+ this.confirmRebase.fetchRecentChanges();
break;
case RevisionActions.CHERRYPICK:
- this._handleCherrypickTap();
+ this.handleCherrypickTap();
break;
case RevisionActions.DOWNLOAD:
- this._handleDownloadTap();
+ this.handleDownloadTap();
break;
case RevisionActions.SUBMIT:
- if (!this._canSubmitChange()) {
+ if (!this.canSubmitChange()) {
return;
}
- this._showActionDialog(this.$.confirmSubmitDialog);
+ assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+ this.showActionDialog(this.confirmSubmitDialog);
break;
default:
- this._fireAction(
- this._prependSlash(key),
+ this.fireAction(
+ this.prependSlash(key),
assertUIActionInfo(this.revisionActions[key]),
true
);
}
}
- _prependSlash(key: string) {
+ private prependSlash(key: string) {
return key === '/' ? key : `/${key}`;
}
/**
* _hasKnownChainState set to true true if hasParent is defined (can be
* either true or false). set to false otherwise.
+ *
+ * private but used in test
*/
- _computeChainState() {
+ computeChainState() {
this._hasKnownChainState = true;
}
- _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+ // private but used in test
+ calculateDisabled(action: UIActionInfo) {
if (action.__key === 'rebase') {
// Rebase button is only disabled when change has no parent(s).
- return hasKnownChainState === false;
+ return this._hasKnownChainState === false;
}
return !action.enabled;
}
- _handleConfirmDialogCancel() {
- this._hideAllDialogs();
+ private handleConfirmDialogCancel() {
+ this.hideAllDialogs();
}
- _hideAllDialogs() {
- const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+ private hideAllDialogs() {
+ assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+ const dialogEls = queryAll(this, '.confirmDialog');
for (const dialogEl of dialogEls) {
(dialogEl as HTMLElement).hidden = true;
}
- this.$.overlay.close();
+ assertIsDefined(this.overlay, 'overlay');
+ this.overlay.close();
}
- _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
- const el = this.$.confirmRebase;
+ // private but used in test
+ handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+ assertIsDefined(this.confirmRebase, 'confirmRebase');
+ assertIsDefined(this.overlay, 'overlay');
+ const el = this.confirmRebase;
const payload = {base: e.detail.base};
- this.$.overlay.close();
+ this.overlay.close();
el.hidden = true;
- this._fireAction(
+ this.fireAction(
'/rebase',
assertUIActionInfo(this.revisionActions.rebase),
true,
@@ -1395,16 +1726,20 @@
);
}
- _handleCherrypickConfirm() {
- this._handleCherryPickRestApi(false);
+ // private but used in test
+ handleCherrypickConfirm() {
+ this.handleCherryPickRestApi(false);
}
- _handleCherrypickConflictConfirm() {
- this._handleCherryPickRestApi(true);
+ // private but used in test
+ handleCherrypickConflictConfirm() {
+ this.handleCherryPickRestApi(true);
}
- _handleCherryPickRestApi(conflicts: boolean) {
- const el = this.$.confirmCherrypick;
+ private handleCherryPickRestApi(conflicts: boolean) {
+ assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+ assertIsDefined(this.overlay, 'overlay');
+ const el = this.confirmCherrypick;
if (!el.branch) {
fireAlert(this, ERR_BRANCH_EMPTY);
return;
@@ -1413,9 +1748,9 @@
fireAlert(this, ERR_COMMIT_EMPTY);
return;
}
- this.$.overlay.close();
+ this.overlay.close();
el.hidden = true;
- this._fireAction(
+ this.fireAction(
'/cherrypick',
assertUIActionInfo(this.revisionActions.cherrypick),
true,
@@ -1428,29 +1763,34 @@
);
}
- _handleMoveConfirm() {
- const el = this.$.confirmMove;
+ // private but used in test
+ handleMoveConfirm() {
+ assertIsDefined(this.confirmMove, 'confirmMove');
+ assertIsDefined(this.overlay, 'overlay');
+ const el = this.confirmMove;
if (!el.branch) {
fireAlert(this, ERR_BRANCH_EMPTY);
return;
}
- this.$.overlay.close();
+ this.overlay.close();
el.hidden = true;
- this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+ this.fireAction('/move', assertUIActionInfo(this.actions.move), false, {
destination_branch: el.branch,
message: el.message,
});
}
- _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+ private handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+ assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+ assertIsDefined(this.overlay, 'overlay');
const revertType = e.detail.revertType;
const message = e.detail.message;
- const el = this.$.confirmRevertDialog;
- this.$.overlay.close();
+ const el = this.confirmRevertDialog;
+ this.overlay.close();
el.hidden = true;
switch (revertType) {
case RevertType.REVERT_SINGLE_CHANGE:
- this._fireAction(
+ this.fireAction(
'/revert',
assertUIActionInfo(this.actions.revert),
false,
@@ -1460,7 +1800,7 @@
case RevertType.REVERT_SUBMISSION:
// TODO(dhruvsri): replace with this.actions.revert_submission once
// BE starts sending it again
- this._fireAction(
+ this.fireAction(
'/revert_submission',
{__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo,
false,
@@ -1472,11 +1812,14 @@
}
}
- _handleAbandonDialogConfirm() {
- const el = this.$.confirmAbandonDialog;
- this.$.overlay.close();
+ // private but used in test
+ handleAbandonDialogConfirm() {
+ assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+ assertIsDefined(this.overlay, 'overlay');
+ const el = this.confirmAbandonDialog;
+ this.overlay.close();
el.hidden = true;
- this._fireAction(
+ this.fireAction(
'/abandon',
assertUIActionInfo(this.actions.abandon),
false,
@@ -1486,58 +1829,62 @@
);
}
- _handleCreateFollowUpChange() {
- this.$.createFollowUpChange.handleCreateChange();
- this._handleCloseCreateFollowUpChange();
+ private handleCreateFollowUpChange() {
+ assertIsDefined(this.createFollowUpChange, 'createFollowUpChange');
+ this.createFollowUpChange.handleCreateChange();
+ this.handleCloseCreateFollowUpChange();
}
- _handleCloseCreateFollowUpChange() {
- this.$.overlay.close();
+ private handleCloseCreateFollowUpChange() {
+ assertIsDefined(this.overlay, 'overlay');
+ this.overlay.close();
}
- _handleDeleteConfirm() {
- this._hideAllDialogs();
- this._fireAction(
+ private handleDeleteConfirm() {
+ this.hideAllDialogs();
+ this.fireAction(
'/',
assertUIActionInfo(this.actions[ChangeActions.DELETE]),
false
);
}
- _handleDeleteEditConfirm() {
- this._hideAllDialogs();
+ private handleDeleteEditConfirm() {
+ this.hideAllDialogs();
// We need to make sure that all cached version of a change
// edit are deleted.
this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
- this._fireAction(
+ this.fireAction(
'/edit',
assertUIActionInfo(this.actions.deleteEdit),
false
);
}
- _handleSubmitConfirm() {
- if (!this._canSubmitChange()) {
+ // private but used in test
+ handleSubmitConfirm() {
+ if (!this.canSubmitChange()) {
return;
}
- this._hideAllDialogs();
- this._fireAction(
+ this.hideAllDialogs();
+ this.fireAction(
'/submit',
assertUIActionInfo(this.revisionActions.submit),
true
);
}
- _getActionOverflowIndex(type: string, key: string) {
- return this._overflowActions.findIndex(
+ private getActionOverflowIndex(type: string, key: string) {
+ return this.overflowActions.findIndex(
action => action.type === type && action.key === key
);
}
- _setLoadingOnButtonWithKey(type: string, key: string) {
- this._actionLoadingMessage = this._computeLoadingLabel(key);
+ // private but used in test
+ setLoadingOnButtonWithKey(type: string, key: string) {
+ this.actionLoadingMessage = this.computeLoadingLabel(key);
let buttonKey = key;
// TODO(dhruvsri): clean this up later
// If key is revert-submission, then button key should be 'revert'
@@ -1547,14 +1894,12 @@
}
// If the action appears in the overflow menu.
- if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
- this.push(
- '_disabledMenuActions',
- buttonKey === '/' ? 'delete' : buttonKey
- );
+ if (this.getActionOverflowIndex(type, buttonKey) !== -1) {
+ this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
+ this.requestUpdate('disabledMenuActions');
return () => {
- this._actionLoadingMessage = '';
- this._disabledMenuActions = [];
+ this.actionLoadingMessage = '';
+ this.disabledMenuActions = [];
};
}
@@ -1568,38 +1913,41 @@
buttonEl.setAttribute('loading', 'true');
buttonEl.disabled = true;
return () => {
- this._actionLoadingMessage = '';
+ this.actionLoadingMessage = '';
buttonEl.removeAttribute('loading');
buttonEl.disabled = false;
};
}
- _fireAction(
+ // private but used in test
+ fireAction(
endpoint: string,
action: UIActionInfo,
revAction: boolean,
payload?: RequestPayload
) {
- const cleanupFn = this._setLoadingOnButtonWithKey(
+ const cleanupFn = this.setLoadingOnButtonWithKey(
action.__type,
action.__key
);
- this._send(
+ this.send(
action.method,
payload,
endpoint,
revAction,
cleanupFn,
action
- ).then(res => this._handleResponse(action, res));
+ ).then(res => this.handleResponse(action, res));
}
- _showActionDialog(dialog: ChangeActionDialog) {
- this._hideAllDialogs();
+ // private but used in test
+ showActionDialog(dialog: ChangeActionDialog) {
+ this.hideAllDialogs();
if (dialog.init) dialog.init();
dialog.hidden = false;
- this.$.overlay.open().then(() => {
+ assertIsDefined(this.overlay, 'overlay');
+ this.overlay.open().then(() => {
if (dialog.resetFocus) {
dialog.resetFocus();
}
@@ -1608,7 +1956,8 @@
// TODO(rmistry): Redo this after
// https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
- _setReviewOnRevert(newChangeId: NumericChangeId) {
+ // private but used in test
+ setReviewOnRevert(newChangeId: NumericChangeId) {
const review = this.jsAPI.getReviewPostRevert(this.change);
if (!review) {
return Promise.resolve(undefined);
@@ -1616,7 +1965,8 @@
return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
}
- _handleResponse(action: UIActionInfo, response?: Response) {
+ // private but used in test
+ handleResponse(action: UIActionInfo, response?: Response) {
if (!response) {
return;
}
@@ -1624,8 +1974,8 @@
switch (action.__key) {
case ChangeActions.REVERT: {
const revertChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
- this._waitForChangeReachable(revertChangeInfo._number)
- .then(() => this._setReviewOnRevert(revertChangeInfo._number))
+ this.waitForChangeReachable(revertChangeInfo._number)
+ .then(() => this.setReviewOnRevert(revertChangeInfo._number))
.then(() => {
GerritNav.navigateToChange(revertChangeInfo);
});
@@ -1633,11 +1983,9 @@
}
case RevisionActions.CHERRYPICK: {
const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
- this._waitForChangeReachable(cherrypickChangeInfo._number).then(
- () => {
- GerritNav.navigateToChange(cherrypickChangeInfo);
- }
- );
+ this.waitForChangeReachable(cherrypickChangeInfo._number).then(() => {
+ GerritNav.navigateToChange(cherrypickChangeInfo);
+ });
break;
}
case ChangeActions.DELETE:
@@ -1661,7 +2009,7 @@
)
return;
/* If there is only 1 change then gerrit will automatically
- redirect to that change */
+ redirect to that change */
GerritNav.navigateToSearchQuery(
`topic: ${revertSubmistionInfo.revert_changes[0].topic}`
);
@@ -1674,7 +2022,8 @@
});
}
- _handleResponseError(
+ // private but used in test
+ handleResponseError(
action: UIActionInfo,
response: Response | undefined | null,
body?: RequestPayload
@@ -1696,7 +2045,11 @@
body &&
!(body as CherryPickInput).allow_conflicts
) {
- this._showActionDialog(this.$.confirmCherrypickConflict);
+ assertIsDefined(
+ this.confirmCherrypickConflict,
+ 'confirmCherrypickConflict'
+ );
+ this.showActionDialog(this.confirmCherrypickConflict);
return;
}
}
@@ -1714,7 +2067,8 @@
});
}
- _send(
+ // private but used in test
+ send(
method: HttpMethod | undefined,
payload: RequestPayload | undefined,
actionEndpoint: string,
@@ -1724,7 +2078,7 @@
): Promise<Response | undefined> {
const handleError: ErrorCallback = response => {
cleanupFn.call(this);
- this._handleResponseError(action, response, payload);
+ this.handleResponseError(action, response, payload);
};
const change = this.change;
const changeNum = this.changeNum;
@@ -1774,11 +2128,13 @@
});
}
- _handleCherrypickTap() {
+ // private but used in test
+ handleCherrypickTap() {
if (!this.change) {
throw new Error('The change property must be set');
}
- this.$.confirmCherrypick.branch = '' as BranchName;
+ assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+ this.confirmCherrypick.branch = '' as BranchName;
const query = `topic: "${this.change.topic}"`;
const options = listChangesOptionsToHex(
ListChangesOption.MESSAGES,
@@ -1791,52 +2147,61 @@
this.reporting.error(new Error('getChanges returns undefined'));
return;
}
- this.$.confirmCherrypick.updateChanges(changes);
- this._showActionDialog(this.$.confirmCherrypick);
+ this.confirmCherrypick!.updateChanges(changes);
+ this.showActionDialog(this.confirmCherrypick!);
});
}
- _handleMoveTap() {
- this.$.confirmMove.branch = '' as BranchName;
- this.$.confirmMove.message = '';
- this._showActionDialog(this.$.confirmMove);
+ // private but used in test
+ handleMoveTap() {
+ assertIsDefined(this.confirmMove, 'confirmMove');
+ this.confirmMove.branch = '' as BranchName;
+ this.confirmMove.message = '';
+ this.showActionDialog(this.confirmMove);
}
- _handleDownloadTap() {
+ // private but used in test
+ handleDownloadTap() {
fireEvent(this, 'download-tap');
}
- _handleIncludedInTap() {
+ // private but used in test
+ handleIncludedInTap() {
fireEvent(this, 'included-tap');
}
- _handleDeleteTap() {
- this._showActionDialog(this.$.confirmDeleteDialog);
+ // private but used in test
+ handleDeleteTap() {
+ assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
+ this.showActionDialog(this.confirmDeleteDialog);
}
- _handleDeleteEditTap() {
- this._showActionDialog(this.$.confirmDeleteEditDialog);
+ // private but used in test
+ handleDeleteEditTap() {
+ assertIsDefined(this.confirmDeleteEditDialog, 'confirmDeleteEditDialog');
+ this.showActionDialog(this.confirmDeleteEditDialog);
}
- _handleFollowUpTap() {
- this._showActionDialog(this.$.createFollowUpDialog);
+ private handleFollowUpTap() {
+ assertIsDefined(this.createFollowUpDialog, 'createFollowUpDialog');
+ this.showActionDialog(this.createFollowUpDialog);
}
- _handleWipTap() {
+ private handleWipTap() {
if (!this.actions.wip) {
return;
}
- this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+ this.fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
}
- _handlePublishEditTap() {
+ private handlePublishEditTap() {
if (!this.actions.publishEdit) return;
// We need to make sure that all cached version of a change
// edit are deleted.
this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
- this._fireAction(
+ this.fireAction(
'/edit:publish',
assertUIActionInfo(this.actions.publishEdit),
false,
@@ -1844,93 +2209,85 @@
);
}
- _handleRebaseEditTap() {
+ private handleRebaseEditTap() {
if (!this.actions.rebaseEdit) {
return;
}
- this._fireAction(
+ this.fireAction(
'/edit:rebase',
assertUIActionInfo(this.actions.rebaseEdit),
false
);
}
- _handleHideBackgroundContent() {
- this.$.mainContent.classList.add('overlayOpen');
+ // private but used in test
+ handleHideBackgroundContent() {
+ assertIsDefined(this.mainContent, 'mainContent');
+ this.mainContent.classList.add('overlayOpen');
}
- _handleShowBackgroundContent() {
- this.$.mainContent.classList.remove('overlayOpen');
+ // private but used in test
+ handleShowBackgroundContent() {
+ assertIsDefined(this.mainContent, 'mainContent');
+ this.mainContent.classList.remove('overlayOpen');
}
/**
* Merge sources of change actions into a single ordered array of action
* values.
*/
- _computeAllActions(
- changeActionsRecord: PolymerDeepPropertyChange<
- ActionNameToActionInfoMap,
- ActionNameToActionInfoMap
- >,
- revisionActionsRecord: PolymerDeepPropertyChange<
- ActionNameToActionInfoMap,
- ActionNameToActionInfoMap
- >,
- primariesRecord: PolymerDeepPropertyChange<
- PrimaryActionKey[],
- PrimaryActionKey[]
- >,
- additionalActionsRecord: PolymerDeepPropertyChange<
- UIActionInfo[],
- UIActionInfo[]
- >,
+ private computeAllActions(
+ changeActions: ActionNameToActionInfoMap,
+ revisionActions: ActionNameToActionInfoMap,
+ primariesActions: PrimaryActionKey[],
+ additionalActions: UIActionInfo[],
change?: ChangeInfo
): UIActionInfo[] {
// Polymer 2: check for undefined
if (
[
- changeActionsRecord,
- revisionActionsRecord,
- primariesRecord,
- additionalActionsRecord,
+ changeActions,
+ revisionActions,
+ primariesActions,
+ additionalActions,
change,
].includes(undefined)
) {
return [];
}
- const revisionActionValues = this._getActionValues(
- revisionActionsRecord,
- primariesRecord,
- additionalActionsRecord,
+ const revisionActionValues = this.getActionValues(
+ revisionActions,
+ primariesActions,
+ additionalActions,
ActionType.REVISION
);
- const changeActionValues = this._getActionValues(
- changeActionsRecord,
- primariesRecord,
- additionalActionsRecord,
+ const changeActionValues = this.getActionValues(
+ changeActions,
+ primariesActions,
+ additionalActions,
ActionType.CHANGE
);
- const quickApprove = this._getQuickApproveAction();
+ const quickApprove = this.getQuickApproveAction();
if (quickApprove) {
changeActionValues.unshift(quickApprove);
}
return revisionActionValues
.concat(changeActionValues)
- .sort((a, b) => this._actionComparator(a, b))
+ .sort((a, b) => this.actionComparator(a, b))
.map(action => {
if (ACTIONS_WITH_ICONS.has(action.__key)) {
action.icon = action.__key;
}
return action;
})
- .filter(action => !this._shouldSkipAction(action));
+ .filter(action => !this.shouldSkipAction(action));
}
- _getActionPriority(action: UIActionInfo) {
+ private getActionPriority(action: UIActionInfo) {
if (action.__type && action.__key) {
- const overrideAction = this._actionPriorityOverrides.find(
+ const overrideAction = this.actionPriorityOverrides.find(
i => i.type === action.__type && i.key === action.__key
);
@@ -1952,10 +2309,12 @@
/**
* Sort comparator to define the order of change actions.
+ *
+ * private but used in test
*/
- _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+ actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
const priorityDelta =
- this._getActionPriority(actionA) - this._getActionPriority(actionB);
+ this.getActionPriority(actionA) - this.getActionPriority(actionB);
// Sort by the button label if same priority.
if (priorityDelta === 0) {
return actionA.label > actionB.label ? 1 : -1;
@@ -1964,40 +2323,38 @@
}
}
- _shouldSkipAction(action: UIActionInfo) {
+ private shouldSkipAction(action: UIActionInfo) {
return SKIP_ACTION_KEYS.includes(action.__key);
}
- _computeTopLevelActions(
- actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
- hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
+ computeTopLevelActions(
+ action: UIActionInfo[],
+ hiddenActions: string[],
editMode: boolean
): UIActionInfo[] {
- const hiddenActions = hiddenActionsRecord.base || [];
- return actionRecord.base.filter(a => {
+ return action.filter(a => {
if (hiddenActions.includes(a.__key)) return false;
if (editMode) return EDIT_ACTIONS.has(a.__key);
- return this._getActionOverflowIndex(a.__type, a.__key) === -1;
+ return this.getActionOverflowIndex(a.__type, a.__key) === -1;
});
}
- _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
- this._topLevelPrimaryActions = _topLevelActions.filter(
+ private filterPrimaryActions(topLevelActions?: UIActionInfo[]) {
+ this.topLevelPrimaryActions = topLevelActions!.filter(
action => action.__primary
);
- this._topLevelSecondaryActions = _topLevelActions.filter(
+ this.topLevelSecondaryActions = topLevelActions!.filter(
action => !action.__primary
);
}
- _computeMenuActions(
- actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
- hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+ private computeMenuActions(
+ action: UIActionInfo[],
+ hiddenActions: string[]
): MenuAction[] {
- const hiddenActions = hiddenActionsRecord.base || [];
- return actionRecord.base
+ return action
.filter(a => {
- const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+ const overflow = this.getActionOverflowIndex(a.__type, a.__key) !== -1;
return overflow && !hiddenActions.includes(a.__key);
})
.map(action => {
@@ -2014,15 +2371,6 @@
});
}
- _computeRebaseOnCurrent(
- revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
- ) {
- if (revisionRebaseAction) {
- return !!revisionRebaseAction.enabled;
- }
- return null;
- }
-
/**
* Occasionally, a change created by a change action is not yet known to the
* API for a brief time. Wait for the given change number to be recognized.
@@ -2030,8 +2378,9 @@
* Returns a promise that resolves with true if a request is recognized, or
* false if the change was never recognized after all attempts.
*
+ * private but used in test
*/
- _waitForChangeReachable(changeNum: NumericChangeId) {
+ waitForChangeReachable(changeNum: NumericChangeId) {
let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
return new Promise(resolve => {
const check = () => {
@@ -2057,24 +2406,19 @@
});
}
- _handleEditTap() {
+ private handleEditTap() {
this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
}
- _handleStopEditTap() {
+ private handleStopEditTap() {
this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
}
-
- _computeHasTooltip(title?: string) {
- return !!title;
- }
-
- _computeHasIcon(action: UIActionInfo) {
- return action.icon ? '' : 'hidden';
- }
}
declare global {
+ interface HTMLElementEventMap {
+ 'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>;
+ }
interface HTMLElementTagNameMap {
'gr-change-actions': GrChangeActions;
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
deleted file mode 100644
index 17ca7cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- :host {
- display: flex;
- font-family: var(--font-family);
- }
- #actionLoadingMessage,
- #mainContent,
- section {
- display: flex;
- }
- #actionLoadingMessage,
- gr-button,
- gr-dropdown {
- /* px because don't have the same font size */
- margin-left: 8px;
- }
- gr-button {
- display: block;
- }
- #actionLoadingMessage {
- align-items: center;
- color: var(--deemphasized-text-color);
- }
- #confirmSubmitDialog .changeSubject {
- margin: var(--spacing-l);
- text-align: center;
- }
- iron-icon {
- color: inherit;
- margin-right: var(--spacing-xs);
- }
- #moreActions iron-icon {
- margin: 0;
- }
- #moreMessage,
- .hidden {
- display: none;
- }
- @media screen and (max-width: 50em) {
- #mainContent {
- flex-wrap: wrap;
- }
- gr-button {
- --gr-button-padding: var(--spacing-m);
- white-space: nowrap;
- }
- gr-button,
- gr-dropdown {
- margin: 0;
- }
- #actionLoadingMessage {
- margin: var(--spacing-m);
- text-align: center;
- }
- #moreMessage {
- display: inline;
- }
- }
- </style>
- <div id="mainContent">
- <span id="actionLoadingMessage" hidden$="[[!_actionLoadingMessage]]">
- [[_actionLoadingMessage]]</span
- >
- <section
- id="primaryActions"
- hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
- >
- <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
- <gr-tooltip-content
- title$="[[action.title]]"
- has-tooltip="[[_computeHasTooltip(action.title)]]"
- position-below="true"
- >
- <gr-button
- link=""
- data-action-key$="[[action.__key]]"
- class$="[[action.__key]]"
- data-action-type$="[[action.__type]]"
- data-label$="[[action.label]]"
- disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
- on-click="_handleActionTap"
- >
- <iron-icon
- class$="[[_computeHasIcon(action)]]"
- icon$="gr-icons:[[action.icon]]"
- ></iron-icon>
- [[action.label]]
- </gr-button>
- </gr-tooltip-content>
- </template>
- </section>
- <section
- id="secondaryActions"
- hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
- >
- <template
- is="dom-repeat"
- items="[[_topLevelSecondaryActions]]"
- as="action"
- >
- <gr-tooltip-content
- title$="[[action.title]]"
- has-tooltip="[[_computeHasTooltip(action.title)]]"
- position-below="true"
- >
- <gr-button
- link=""
- data-action-key$="[[action.__key]]"
- class$="[[action.__key]]"
- data-action-type$="[[action.__type]]"
- data-label$="[[action.label]]"
- disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
- on-click="_handleActionTap"
- >
- <iron-icon
- class$="[[_computeHasIcon(action)]]"
- icon$="gr-icons:[[action.icon]]"
- ></iron-icon>
- [[action.label]]
- </gr-button>
- </gr-tooltip-content>
- </template>
- </section>
- <gr-button hidden$="[[!_loading]]" disabled=""
- >Loading actions...</gr-button
- >
- <gr-dropdown
- id="moreActions"
- link=""
- vertical-offset="32"
- horizontal-align="right"
- on-tap-item="_handleOverflowItemTap"
- hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
- disabled-ids="[[_disabledMenuActions]]"
- items="[[_menuActions]]"
- >
- <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
- </iron-icon>
- <span id="moreMessage">More</span>
- </gr-dropdown>
- </div>
- <gr-overlay id="overlay" with-backdrop="">
- <gr-confirm-rebase-dialog
- id="confirmRebase"
- class="confirmDialog"
- change-number="[[change._number]]"
- on-confirm="_handleRebaseConfirm"
- on-cancel="_handleConfirmDialogCancel"
- branch="[[change.branch]]"
- has-parent="[[hasParent]]"
- rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
- hidden=""
- ></gr-confirm-rebase-dialog>
- <gr-confirm-cherrypick-dialog
- id="confirmCherrypick"
- class="confirmDialog"
- change-status="[[changeStatus]]"
- commit-message="[[commitMessage]]"
- commit-num="[[commitNum]]"
- on-confirm="_handleCherrypickConfirm"
- on-cancel="_handleConfirmDialogCancel"
- project="[[change.project]]"
- hidden=""
- ></gr-confirm-cherrypick-dialog>
- <gr-confirm-cherrypick-conflict-dialog
- id="confirmCherrypickConflict"
- class="confirmDialog"
- on-confirm="_handleCherrypickConflictConfirm"
- on-cancel="_handleConfirmDialogCancel"
- hidden=""
- ></gr-confirm-cherrypick-conflict-dialog>
- <gr-confirm-move-dialog
- id="confirmMove"
- class="confirmDialog"
- on-confirm="_handleMoveConfirm"
- on-cancel="_handleConfirmDialogCancel"
- project="[[change.project]]"
- hidden=""
- ></gr-confirm-move-dialog>
- <gr-confirm-revert-dialog
- id="confirmRevertDialog"
- class="confirmDialog"
- on-confirm="_handleRevertDialogConfirm"
- on-cancel="_handleConfirmDialogCancel"
- hidden=""
- ></gr-confirm-revert-dialog>
- <gr-confirm-abandon-dialog
- id="confirmAbandonDialog"
- class="confirmDialog"
- on-confirm="_handleAbandonDialogConfirm"
- on-cancel="_handleConfirmDialogCancel"
- hidden=""
- ></gr-confirm-abandon-dialog>
- <gr-confirm-submit-dialog
- id="confirmSubmitDialog"
- class="confirmDialog"
- action="[[_revisionSubmitAction]]"
- on-cancel="_handleConfirmDialogCancel"
- on-confirm="_handleSubmitConfirm"
- hidden=""
- ></gr-confirm-submit-dialog>
- <gr-dialog
- id="createFollowUpDialog"
- class="confirmDialog"
- confirm-label="Create"
- on-confirm="_handleCreateFollowUpChange"
- on-cancel="_handleCloseCreateFollowUpChange"
- >
- <div class="header" slot="header">Create Follow-Up Change</div>
- <div class="main" slot="main">
- <gr-create-change-dialog
- id="createFollowUpChange"
- branch="[[change.branch]]"
- base-change="[[change.id]]"
- repo-name="[[change.project]]"
- private-by-default="[[privateByDefault]]"
- ></gr-create-change-dialog>
- </div>
- </gr-dialog>
- <gr-dialog
- id="confirmDeleteDialog"
- class="confirmDialog"
- confirm-label="Delete"
- confirm-on-enter=""
- on-cancel="_handleConfirmDialogCancel"
- on-confirm="_handleDeleteConfirm"
- >
- <div class="header" slot="header">Delete Change</div>
- <div class="main" slot="main">
- Do you really want to delete the change?
- </div>
- </gr-dialog>
- <gr-dialog
- id="confirmDeleteEditDialog"
- class="confirmDialog"
- confirm-label="Delete"
- confirm-on-enter=""
- on-cancel="_handleConfirmDialogCancel"
- on-confirm="_handleDeleteEditConfirm"
- >
- <div class="header" slot="header">Delete Change Edit</div>
- <div class="main" slot="main">Do you really want to delete the edit?</div>
- </gr-dialog>
- </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 50ab91c..3d97769 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -54,23 +54,28 @@
TopicName,
} from '../../../types/common';
import {ActionType} from '../../../api/change-actions';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
import {SinonFakeTimers} from 'sinon';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {getAppContext} from '../../../services/app-context';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRebaseDialog} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
suite('gr-change-actions tests', () => {
let element: GrChangeActions;
suite('basic tests', () => {
- setup(() => {
+ setup(async () => {
stubRestApi('getChangeRevisionActions').returns(
Promise.resolve({
cherrypick: {
@@ -127,7 +132,9 @@
.stub(getPluginLoader(), 'awaitPluginsLoaded')
.returns(Promise.resolve());
- element = basicFixture.instantiate();
+ element = await fixture<GrChangeActions>(html`
+ <gr-change-actions></gr-change-actions>
+ `);
element.change = createChangeViewChange();
element.changeNum = 42 as NumericChangeId;
element.latestPatchNum = 2 as PatchSetNum;
@@ -144,61 +151,41 @@
};
stubRestApi('getRepoBranches').returns(Promise.resolve([]));
- return element.reload();
+ await element.updateComplete;
+ await element.reload();
});
test('show-revision-actions event should fire', async () => {
- const spy = sinon.spy(element, '_sendShowRevisionActions');
+ const spy = sinon.spy(element, 'sendShowRevisionActions');
element.reload();
- await flush();
+ await element.updateComplete;
assert.isTrue(spy.called);
});
test('primary and secondary actions split properly', () => {
// Submit should be the only primary action.
- assert.equal(element._topLevelPrimaryActions!.length, 1);
- assert.equal(element._topLevelPrimaryActions![0].label, 'Submit');
+ assert.equal(element.topLevelPrimaryActions!.length, 1);
+ assert.equal(element.topLevelPrimaryActions![0].label, 'Submit');
assert.equal(
- element._topLevelSecondaryActions!.length,
- element._topLevelActions!.length - 1
+ element.topLevelSecondaryActions!.length,
+ element.topLevelActions!.length - 1
);
});
test('revert submission action is skipped', () => {
assert.equal(
- element._allActionValues.filter(action => action.__key === 'submit')
+ element.allActionValues.filter(action => action.__key === 'submit')
.length,
1
);
assert.equal(
- element._allActionValues.filter(
+ element.allActionValues.filter(
action => action.__key === 'revert_submission'
).length,
0
);
});
- test('_shouldHideActions', () => {
- assert.isTrue(element._shouldHideActions(undefined, true));
- assert.isTrue(
- element._shouldHideActions(
- {base: [] as UIActionInfo[]} as PolymerDeepPropertyChange<
- UIActionInfo[],
- UIActionInfo[]
- >,
- false
- )
- );
- assert.isFalse(
- element._shouldHideActions(
- {
- base: [{__key: 'test'}] as UIActionInfo[],
- } as PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
- false
- )
- );
- });
-
test('plugin revision actions', async () => {
const stub = stubRestApi('getChangeActionURL').returns(
Promise.resolve('the-url')
@@ -207,7 +194,7 @@
'plugin~action': {},
};
assert.isOk(element.revisionActions['plugin~action']);
- await flush();
+ await element.updateComplete;
assert.isTrue(
stub.calledWith(
element.changeNum,
@@ -229,7 +216,7 @@
'plugin~action': {},
};
assert.isOk(element.actions['plugin~action']);
- await flush();
+ await element.updateComplete;
assert.isTrue(
stub.calledWith(element.changeNum, undefined, '/plugin~action')
);
@@ -266,7 +253,7 @@
});
test('hide revision action', async () => {
- await flush();
+ await element.updateComplete;
let buttonEl: Element | undefined = queryAndAssert(
element,
'[data-action-key="submit"]'
@@ -277,14 +264,8 @@
element.RevisionActions.SUBMIT,
true
);
- assert.lengthOf(element._hiddenActions, 1);
- element.setActionHidden(
- element.ActionType.REVISION,
- element.RevisionActions.SUBMIT,
- true
- );
- assert.lengthOf(element._hiddenActions, 1);
- await flush();
+ assert.lengthOf(element.hiddenActions, 1);
+ await element.updateComplete;
buttonEl = query(element, '[data-action-key="submit"]');
assert.isNotOk(buttonEl);
@@ -293,31 +274,35 @@
element.RevisionActions.SUBMIT,
false
);
- await flush();
+ await element.updateComplete;
buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
assert.isFalse(buttonEl.hasAttribute('hidden'));
});
test('buttons exist', async () => {
- element._loading = false;
- await flush();
+ element.loading = false;
+ await element.updateComplete;
const buttonEls = queryAll(element, 'gr-button');
- const menuItems = element.$.moreActions.items;
+ const menuItems = queryAndAssert<GrDropdown>(
+ element,
+ '#moreActions'
+ ).items;
// Total button number is one greater than the number of total actions
// due to the existence of the overflow menu trigger.
assert.equal(
buttonEls.length + menuItems!.length,
- element._allActionValues.length + 1
+ element.allActionValues.length + 1
);
assert.isFalse(element.hidden);
});
test('delete buttons have explicit labels', async () => {
- await flush();
- const deleteItems = element.$.moreActions.items!.filter(item =>
- item.id!.startsWith('delete')
- );
+ await element.updateComplete;
+ const deleteItems = queryAndAssert<GrDropdown>(
+ element,
+ '#moreActions'
+ ).items!.filter(item => item.id!.startsWith('delete'));
assert.equal(deleteItems.length, 1);
assert.equal(deleteItems[0].name, 'Delete change');
});
@@ -335,10 +320,10 @@
rev2: revObj,
},
};
- assert.deepEqual(element._getRevision(change, 2 as PatchSetNum), revObj);
+ assert.deepEqual(element.getRevision(change, 2 as PatchSetNum), revObj);
});
- test('_actionComparator sort order', () => {
+ test('actionComparator sort order', () => {
const actions = [
{label: '123', __type: ActionType.CHANGE, __key: 'review'},
{label: 'abc-ro', __type: ActionType.REVISION, __key: 'random'},
@@ -354,16 +339,18 @@
const result = actions.slice();
result.reverse();
- result.sort(element._actionComparator.bind(element));
+ result.sort(element.actionComparator.bind(element));
assert.deepEqual(result, actions);
});
test('submit change', async () => {
- const showSpy = sinon.spy(element, '_showActionDialog');
+ const showSpy = sinon.spy(element, 'showActionDialog');
stubRestApi('getFromProjectLookup').returns(
Promise.resolve('test' as RepoName)
);
- sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+ sinon
+ .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
+ .returns(Promise.resolve());
element.change = {
...createChangeViewChange(),
revisions: {
@@ -373,25 +360,36 @@
};
element.latestPatchNum = 2 as PatchSetNum;
- const submitButton = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="submit"]'
- );
- tap(submitButton);
+ ).click();
- await flush();
- assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+ await element.updateComplete;
+ assert.isTrue(
+ showSpy.calledWith(
+ queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+ )
+ );
});
test('submit change, tap on icon', async () => {
const submitted = mockPromise();
sinon
- .stub(element.$.confirmSubmitDialog, 'resetFocus')
+ .stub(
+ queryAndAssert<GrConfirmSubmitDialog>(
+ element,
+ '#confirmSubmitDialog'
+ ),
+ 'resetFocus'
+ )
.callsFake(() => submitted.resolve());
stubRestApi('getFromProjectLookup').returns(
Promise.resolve('test' as RepoName)
);
- sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+ sinon
+ .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
+ .returns(Promise.resolve());
element.change = {
...createChangeViewChange(),
revisions: {
@@ -401,18 +399,17 @@
};
element.latestPatchNum = 2 as PatchSetNum;
- const submitIcon = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="submit"] iron-icon'
- );
- tap(submitIcon);
+ ).click();
await submitted;
});
- test('_handleSubmitConfirm', () => {
- const fireStub = sinon.stub(element, '_fireAction');
- sinon.stub(element, '_canSubmitChange').returns(true);
- element._handleSubmitConfirm();
+ test('handleSubmitConfirm', () => {
+ const fireStub = sinon.stub(element, 'fireAction');
+ sinon.stub(element, 'canSubmitChange').returns(true);
+ element.handleSubmitConfirm();
assert.isTrue(fireStub.calledOnce);
assert.deepEqual(fireStub.lastCall.args, [
'/submit',
@@ -421,77 +418,66 @@
]);
});
- test('_handleSubmitConfirm when not able to submit', () => {
- const fireStub = sinon.stub(element, '_fireAction');
- sinon.stub(element, '_canSubmitChange').returns(false);
- element._handleSubmitConfirm();
+ test('handleSubmitConfirm when not able to submit', () => {
+ const fireStub = sinon.stub(element, 'fireAction');
+ sinon.stub(element, 'canSubmitChange').returns(false);
+ element.handleSubmitConfirm();
assert.isFalse(fireStub.called);
});
test('submit change with plugin hook', async () => {
- sinon.stub(element, '_canSubmitChange').callsFake(() => false);
- const fireActionStub = sinon.stub(element, '_fireAction');
- await flush();
- const submitButton = queryAndAssert(
+ sinon.stub(element, 'canSubmitChange').callsFake(() => false);
+ const fireActionStub = sinon.stub(element, 'fireAction');
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="submit"]'
- );
- tap(submitButton);
+ ).click();
assert.equal(fireActionStub.callCount, 0);
});
- test('chain state', () => {
+ test('chain state', async () => {
assert.equal(element._hasKnownChainState, false);
element.hasParent = true;
+ await element.updateComplete;
assert.equal(element._hasKnownChainState, true);
- element.hasParent = false;
});
- test('_calculateDisabled', () => {
- let hasKnownChainState = false;
+ test('calculateDisabled', () => {
const action = {
__key: 'rebase',
enabled: true,
__type: ActionType.CHANGE,
label: 'l',
};
- assert.equal(
- element._calculateDisabled(action, hasKnownChainState),
- true
- );
+ element._hasKnownChainState = false;
+ assert.equal(element.calculateDisabled(action), true);
action.__key = 'delete';
- assert.equal(
- element._calculateDisabled(action, hasKnownChainState),
- false
- );
+ assert.equal(element.calculateDisabled(action), false);
action.__key = 'rebase';
- hasKnownChainState = true;
- assert.equal(
- element._calculateDisabled(action, hasKnownChainState),
- false
- );
+ element._hasKnownChainState = true;
+ assert.equal(element.calculateDisabled(action), false);
action.enabled = false;
- assert.equal(
- element._calculateDisabled(action, hasKnownChainState),
- false
- );
+ assert.equal(element.calculateDisabled(action), false);
});
test('rebase change', async () => {
- const fireActionStub = sinon.stub(element, '_fireAction');
+ const fireActionStub = sinon.stub(element, 'fireAction');
const fetchChangesStub = sinon
- .stub(element.$.confirmRebase, 'fetchRecentChanges')
+ .stub(
+ queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+ 'fetchRecentChanges'
+ )
.returns(Promise.resolve([]));
element._hasKnownChainState = true;
- await flush();
- const rebaseButton = queryAndAssert(
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="rebase"]'
- );
- tap(rebaseButton);
+ ).click();
const rebaseAction = {
__key: 'rebase',
__type: 'revision',
@@ -502,7 +488,7 @@
title: 'Rebase onto tip of branch or parent change',
};
assert.isTrue(fetchChangesStub.called);
- element._handleRebaseConfirm(
+ element.handleRebaseConfirm(
new CustomEvent('', {detail: {base: '1234'}})
);
assert.deepEqual(fireActionStub.lastCall.args, [
@@ -515,87 +501,108 @@
test('rebase change fires reload event', async () => {
const eventStub = sinon.stub(element, 'dispatchEvent');
- element._handleResponse(
+ await element.handleResponse(
{__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
new Response()
);
- await flush();
assert.isTrue(eventStub.called);
assert.equal(eventStub.lastCall.args[0].type, 'reload');
});
test("rebase dialog gets recent changes each time it's opened", async () => {
const fetchChangesStub = sinon
- .stub(element.$.confirmRebase, 'fetchRecentChanges')
+ .stub(
+ queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+ 'fetchRecentChanges'
+ )
.returns(Promise.resolve([]));
element._hasKnownChainState = true;
- const rebaseButton = queryAndAssert(
+ await element.updateComplete;
+ const rebaseButton = queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="rebase"]'
);
- tap(rebaseButton);
+ rebaseButton.click();
+ await element.updateComplete;
assert.isTrue(fetchChangesStub.calledOnce);
- await flush();
- element.$.confirmRebase.dispatchEvent(
+ await element.updateComplete;
+ queryAndAssert<GrConfirmRebaseDialog>(
+ element,
+ '#confirmRebase'
+ ).dispatchEvent(
new CustomEvent('cancel', {
composed: true,
bubbles: true,
})
);
- tap(rebaseButton);
+ rebaseButton.click();
assert.isTrue(fetchChangesStub.calledTwice);
});
test('two dialogs are not shown at the same time', async () => {
element._hasKnownChainState = true;
- await flush();
- const rebaseButton = queryAndAssert(
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="rebase"]'
+ ).click();
+ await element.updateComplete;
+ assert.isFalse(
+ queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
);
- tap(rebaseButton);
- await flush();
- assert.isFalse(element.$.confirmRebase.hidden);
stubRestApi('getChanges').returns(Promise.resolve([]));
- element._handleCherrypickTap();
- await flush();
- assert.isTrue(element.$.confirmRebase.hidden);
- assert.isFalse(element.$.confirmCherrypick.hidden);
+ element.handleCherrypickTap();
+ await element.updateComplete;
+ assert.isTrue(
+ queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
+ );
+ assert.isFalse(
+ queryAndAssert<GrConfirmCherrypickDialog>(element, '#confirmCherrypick')
+ .hidden
+ );
});
test('fullscreen-overlay-opened hides content', () => {
- const spy = sinon.spy(element, '_handleHideBackgroundContent');
- element.$.overlay.dispatchEvent(
+ const spy = sinon.spy(element, 'handleHideBackgroundContent');
+ queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
new CustomEvent('fullscreen-overlay-opened', {
composed: true,
bubbles: true,
})
);
assert.isTrue(spy.called);
- assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+ assert.isTrue(
+ queryAndAssert<Element>(element, '#mainContent').classList.contains(
+ 'overlayOpen'
+ )
+ );
});
test('fullscreen-overlay-closed shows content', () => {
- const spy = sinon.spy(element, '_handleShowBackgroundContent');
- element.$.overlay.dispatchEvent(
+ const spy = sinon.spy(element, 'handleShowBackgroundContent');
+ queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
new CustomEvent('fullscreen-overlay-closed', {
composed: true,
bubbles: true,
})
);
assert.isTrue(spy.called);
- assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+ assert.isFalse(
+ queryAndAssert<Element>(element, '#mainContent').classList.contains(
+ 'overlayOpen'
+ )
+ );
});
- test('_setReviewOnRevert', () => {
+ test('setReviewOnRevert', () => {
const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
const changeId = 1234 as NumericChangeId;
sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
const saveStub = stubRestApi('saveChangeReview').returns(
Promise.resolve(new Response())
);
- const setReviewOnRevert = element._setReviewOnRevert(changeId) as Promise<
+ const setReviewOnRevert = element.setReviewOnRevert(changeId) as Promise<
undefined | Response
>;
return setReviewOnRevert.then((_res: Response | undefined) => {
@@ -607,14 +614,14 @@
suite('change edits', () => {
test('disableEdit', async () => {
- element.set('editMode', false);
- element.set('editPatchsetLoaded', false);
+ element.editMode = false;
+ element.editBasedOnCurrentPatchSet = false;
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
- element.set('disableEdit', true);
- await flush();
+ element.disableEdit = true;
+ await element.updateComplete;
assert.isNotOk(
query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -630,28 +637,30 @@
});
test('shows confirm dialog for delete edit', async () => {
- element.set('loggedIn', true);
- element.set('editMode', true);
- element.set('editPatchsetLoaded', true);
+ element.loggedIn = true;
+ element.editMode = true;
+ element.editPatchsetLoaded = true;
+ await element.updateComplete;
- const fireActionStub = sinon.stub(element, '_fireAction');
- element._handleDeleteEditTap();
- assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
- tap(
- queryAndAssert(
- queryAndAssert(element, '#confirmDeleteEditDialog'),
- 'gr-button[primary]'
- )
+ const fireActionStub = sinon.stub(element, 'fireAction');
+ element.handleDeleteEditTap();
+ assert.isFalse(
+ queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
);
- await flush();
+ queryAndAssert<GrButton>(
+ queryAndAssert(element, '#confirmDeleteEditDialog'),
+ 'gr-button[primary]'
+ ).click();
+ await element.updateComplete;
assert.equal(fireActionStub.lastCall.args[0], '/edit');
});
test('all cached change edits get deleted on delete edit', async () => {
- element.set('loggedIn', true);
- element.set('editMode', true);
- element.set('editPatchsetLoaded', true);
+ element.loggedIn = true;
+ element.editMode = true;
+ element.editPatchsetLoaded = true;
+ await element.updateComplete;
const storage = getAppContext().storageService;
storage.setEditableContentItem(
@@ -679,31 +688,31 @@
const eraseEditableContentItemsForChangeEditSpy = spyStorage(
'eraseEditableContentItemsForChangeEdit'
);
- sinon.stub(element, '_fireAction');
- element._handleDeleteEditTap();
- assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
- tap(
- queryAndAssert(
- queryAndAssert(element, '#confirmDeleteEditDialog'),
- 'gr-button[primary]'
- )
+ sinon.stub(element, 'fireAction');
+ element.handleDeleteEditTap();
+ assert.isFalse(
+ queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
);
- await flush();
+ queryAndAssert<GrButton>(
+ queryAndAssert(element, '#confirmDeleteEditDialog'),
+ 'gr-button[primary]'
+ ).click();
+ await element.updateComplete;
assert.isTrue(eraseEditableContentItemsForChangeEditSpy.called);
assert.isNotOk(storage.getEditableContentItem('c42_psedit_index.php')!);
assert.isNotOk(storage.getEditableContentItem('c42_ps2_index.php')!);
});
test('edit patchset is loaded, needs rebase', async () => {
- element.set('loggedIn', true);
- element.set('editMode', true);
- element.set('editPatchsetLoaded', true);
+ element.loggedIn = true;
+ element.editMode = true;
+ element.editPatchsetLoaded = true;
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
element.editBasedOnCurrentPatchSet = false;
- await flush();
+ await element.updateComplete;
assert.isNotOk(
query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -715,15 +724,15 @@
});
test('edit patchset is loaded, does not need rebase', async () => {
- element.set('loggedIn', true);
- element.set('editMode', true);
- element.set('editPatchsetLoaded', true);
+ element.loggedIn = true;
+ element.editMode = true;
+ element.editPatchsetLoaded = true;
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
element.editBasedOnCurrentPatchSet = true;
- await flush();
+ await element.updateComplete;
assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
assert.isNotOk(
@@ -735,14 +744,14 @@
});
test('edit mode is loaded, no edit patchset', async () => {
- element.set('loggedIn', true);
- element.set('editMode', true);
- element.set('editPatchsetLoaded', false);
+ element.loggedIn = true;
+ element.editMode = true;
+ element.editPatchsetLoaded = false;
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
- await flush();
+ await element.updateComplete;
assert.isNotOk(
query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -758,14 +767,14 @@
});
test('normal patch set', async () => {
- element.set('loggedIn', true);
- element.set('editMode', false);
- element.set('editPatchsetLoaded', false);
+ element.loggedIn = true;
+ element.editMode = false;
+ element.editPatchsetLoaded = false;
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
- await flush();
+ await element.updateComplete;
assert.isNotOk(
query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -781,17 +790,17 @@
});
test('edit action', async () => {
- element.set('loggedIn', true);
+ element.loggedIn = true;
const editTapped = mockPromise();
element.addEventListener('edit-tap', () => {
editTapped.resolve();
});
- element.set('editMode', true);
+ element.editMode = true;
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
- await flush();
+ await element.updateComplete;
assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
@@ -799,34 +808,33 @@
...createChangeViewChange(),
status: ChangeStatus.MERGED,
};
- await flush();
+ await element.updateComplete;
assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
- element.set('editMode', false);
- await flush();
+ element.editMode = false;
+ await element.updateComplete;
- const editButton = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="edit"]'
- );
- tap(editButton);
+ ).click();
await editTapped;
});
});
test('edit action not shown for logged out user', async () => {
- element.set('loggedIn', false);
- element.set('editMode', false);
- element.set('editPatchsetLoaded', false);
+ element.loggedIn = false;
+ element.editMode = false;
+ element.editPatchsetLoaded = false;
element.change = {
...createChangeViewChange(),
status: ChangeStatus.NEW,
};
- await flush();
+ await element.updateComplete;
assert.isNotOk(
query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -841,12 +849,12 @@
let fireActionStub: sinon.SinonStub;
setup(() => {
- fireActionStub = sinon.stub(element, '_fireAction');
+ fireActionStub = sinon.stub(element, 'fireAction');
sinon.stub(window, 'alert');
});
test('works', async () => {
- element._handleCherrypickTap();
+ element.handleCherrypickTap();
const action = {
__key: 'cherrypick',
__type: 'revision',
@@ -857,24 +865,39 @@
title: 'Cherry pick change to a different branch',
};
- element._handleCherrypickConfirm();
+ element.handleCherrypickConfirm();
assert.equal(fireActionStub.callCount, 0);
- element.$.confirmCherrypick.branch = 'master' as BranchName;
- element._handleCherrypickConfirm();
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).branch = 'master' as BranchName;
+ element.handleCherrypickConfirm();
assert.equal(fireActionStub.callCount, 0); // Still needs a message.
// Add attributes that are used to determine the message.
- element.$.confirmCherrypick.commitMessage = 'foo message';
- element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
- element.$.confirmCherrypick.commitNum = '123' as CommitId;
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).commitMessage = 'foo message';
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).changeStatus = ChangeStatus.NEW;
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).commitNum = '123' as CommitId;
await element.updateComplete;
- element._handleCherrypickConfirm();
+ element.handleCherrypickConfirm();
await element.updateComplete;
const autogrowEl = queryAndAssert<IronAutogrowTextareaElement>(
- element.$.confirmCherrypick,
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ),
'#messageInput'
);
assert.equal(autogrowEl.value, 'foo message');
@@ -893,7 +916,7 @@
});
test('cherry pick even with conflicts', async () => {
- element._handleCherrypickTap();
+ element.handleCherrypickTap();
const action = {
__key: 'cherrypick',
__type: 'revision',
@@ -904,15 +927,27 @@
title: 'Cherry pick change to a different branch',
};
- element.$.confirmCherrypick.branch = 'master' as BranchName;
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).branch = 'master' as BranchName;
// Add attributes that are used to determine the message.
- element.$.confirmCherrypick.commitMessage = 'foo message';
- element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
- element.$.confirmCherrypick.commitNum = '123' as CommitId;
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).commitMessage = 'foo message';
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).changeStatus = ChangeStatus.NEW;
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).commitNum = '123' as CommitId;
await element.updateComplete;
- element._handleCherrypickConflictConfirm();
+ element.handleCherrypickConflictConfirm();
await element.updateComplete;
assert.deepEqual(fireActionStub.lastCall.args, [
@@ -930,10 +965,19 @@
test('branch name cleared when re-open cherrypick', () => {
const emptyBranchName = '';
- element.$.confirmCherrypick.branch = 'master' as BranchName;
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).branch = 'master' as BranchName;
- element._handleCherrypickTap();
- assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+ element.handleCherrypickTap();
+ assert.equal(
+ queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ ).branch,
+ emptyBranchName
+ );
});
suite('cherry pick topics', () => {
@@ -957,20 +1001,28 @@
];
setup(async () => {
stubRestApi('getChanges').returns(Promise.resolve(changes));
- element._handleCherrypickTap();
- await flush();
- const radioButtons = queryAll(
- element.$.confirmCherrypick,
+ element.handleCherrypickTap();
+ await element.updateComplete;
+ const confirmCherrypick = queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ );
+ await element.updateComplete;
+ const radioButtons = queryAll<HTMLInputElement>(
+ confirmCherrypick,
"input[name='cherryPickOptions']"
);
assert.equal(radioButtons.length, 2);
- tap(radioButtons[1]);
- await flush();
+ radioButtons[1].click();
+ await element.updateComplete;
});
test('cherry pick topic dialog is rendered', async () => {
- const dialog = element.$.confirmCherrypick;
- await flush();
+ const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ );
+ await element.updateComplete;
const changesTable = queryAndAssert(dialog, 'table');
const headers = Array.from(changesTable.querySelectorAll('th'));
const expectedHeadings = [
@@ -1006,7 +1058,10 @@
});
test('changes with duplicate project show an error', async () => {
- const dialog = element.$.confirmCherrypick;
+ const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+ element,
+ '#confirmCherrypick'
+ );
const error = queryAndAssert<HTMLSpanElement>(
dialog,
'.error-message'
@@ -1028,7 +1083,7 @@
project: 'A' as RepoName,
},
]);
- await flush();
+ await element.updateComplete;
assert.equal(
error.innerText,
'Two changes cannot be of the same' + ' project'
@@ -1040,8 +1095,8 @@
suite('move change', () => {
let fireActionStub: sinon.SinonStub;
- setup(() => {
- fireActionStub = sinon.stub(element, '_fireAction');
+ setup(async () => {
+ fireActionStub = sinon.stub(element, 'fireAction');
sinon.stub(window, 'alert');
element.actions = {
move: {
@@ -1051,25 +1106,31 @@
enabled: true,
},
};
+ await element.updateComplete;
});
test('works', () => {
- element._handleMoveTap();
+ element.handleMoveTap();
- element._handleMoveConfirm();
+ element.handleMoveConfirm();
assert.equal(fireActionStub.callCount, 0);
- element.$.confirmMove.branch = 'master' as BranchName;
- element._handleMoveConfirm();
+ queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+ 'master' as BranchName;
+ element.handleMoveConfirm();
assert.equal(fireActionStub.callCount, 1);
});
test('branch name cleared when re-open move', () => {
const emptyBranchName = '';
- element.$.confirmMove.branch = 'master' as BranchName;
+ queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+ 'master' as BranchName;
- element._handleMoveTap();
- assert.equal(element.$.confirmMove.branch, emptyBranchName);
+ element.handleMoveTap();
+ assert.equal(
+ queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch,
+ emptyBranchName
+ );
});
});
@@ -1084,20 +1145,24 @@
key
);
element.removeActionButton(key);
- await flush();
+ await element.updateComplete;
assert.notOk(query(element, '[data-action-key="' + key + '"]'));
keyTapped.resolve();
});
- await flush();
- tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+ await element.updateComplete;
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
+ element,
+ '[data-action-key="' + key + '"]'
+ ).click();
await keyTapped;
});
- test('_setLoadingOnButtonWithKey top-level', () => {
+ test('setLoadingOnButtonWithKey top-level', () => {
const key = 'rebase';
const type = 'revision';
- const cleanup = element._setLoadingOnButtonWithKey(type, key);
- assert.equal(element._actionLoadingMessage, 'Rebasing...');
+ const cleanup = element.setLoadingOnButtonWithKey(type, key);
+ assert.equal(element.actionLoadingMessage, 'Rebasing...');
const button = queryAndAssert<GrButton>(
element,
@@ -1112,29 +1177,29 @@
assert.isFalse(button.hasAttribute('loading'));
assert.isFalse(button.disabled);
- assert.isNotOk(element._actionLoadingMessage);
+ assert.isNotOk(element.actionLoadingMessage);
});
- test('_setLoadingOnButtonWithKey overflow menu', () => {
+ test('setLoadingOnButtonWithKey overflow menu', () => {
const key = 'cherrypick';
const type = 'revision';
- const cleanup = element._setLoadingOnButtonWithKey(type, key);
- assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
- assert.include(element._disabledMenuActions, 'cherrypick');
+ const cleanup = element.setLoadingOnButtonWithKey(type, key);
+ assert.equal(element.actionLoadingMessage, 'Cherry-picking...');
+ assert.include(element.disabledMenuActions, 'cherrypick');
assert.isFunction(cleanup);
cleanup();
- assert.notOk(element._actionLoadingMessage);
- assert.notInclude(element._disabledMenuActions, 'cherrypick');
+ assert.notOk(element.actionLoadingMessage);
+ assert.notInclude(element.disabledMenuActions, 'cherrypick');
});
suite('abandon change', () => {
let alertStub: sinon.SinonStub;
let fireActionStub: sinon.SinonStub;
- setup(() => {
- fireActionStub = sinon.stub(element, '_fireAction');
+ setup(async () => {
+ fireActionStub = sinon.stub(element, 'fireAction');
alertStub = sinon.stub(window, 'alert');
element.actions = {
abandon: {
@@ -1144,43 +1209,63 @@
enabled: true,
},
};
- return element.reload();
+ await element.updateComplete;
+ // test
+ await element.reload();
});
test('abandon change with message', async () => {
const newAbandonMsg = 'Test Abandon Message';
- element.$.confirmAbandonDialog.message = newAbandonMsg;
- await flush();
- const abandonButton = queryAndAssert(
+ queryAndAssert<GrConfirmAbandonDialog>(
+ element,
+ '#confirmAbandonDialog'
+ ).message = newAbandonMsg;
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="abandon"]'
- );
- tap(abandonButton);
+ ).click();
- assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+ assert.equal(
+ queryAndAssert<GrConfirmAbandonDialog>(
+ element,
+ '#confirmAbandonDialog'
+ ).message,
+ newAbandonMsg
+ );
});
test('abandon change with no message', async () => {
- await flush();
- const abandonButton = queryAndAssert(
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="abandon"]'
- );
- tap(abandonButton);
+ ).click();
- assert.equal(element.$.confirmAbandonDialog.message, '');
+ assert.equal(
+ queryAndAssert<GrConfirmAbandonDialog>(
+ element,
+ '#confirmAbandonDialog'
+ ).message,
+ ''
+ );
});
test('works', () => {
- element.$.confirmAbandonDialog.message = 'original message';
- const restoreButton = queryAndAssert(
+ queryAndAssert<GrConfirmAbandonDialog>(
+ element,
+ '#confirmAbandonDialog'
+ ).message = 'original message';
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="abandon"]'
- );
- tap(restoreButton);
+ ).click();
- element.$.confirmAbandonDialog.message = 'foo message';
- element._handleAbandonDialogConfirm();
+ queryAndAssert<GrConfirmAbandonDialog>(
+ element,
+ '#confirmAbandonDialog'
+ ).message = 'foo message';
+ element.handleAbandonDialogConfirm();
assert.notOk(alertStub.called);
const action = {
@@ -1206,8 +1291,8 @@
suite('revert change', () => {
let fireActionStub: sinon.SinonStub;
- setup(() => {
- fireActionStub = sinon.stub(element, '_fireAction');
+ setup(async () => {
+ fireActionStub = sinon.stub(element, 'fireAction');
element.commitMessage = 'random commit message';
element.change!.current_revision = 'abcdef' as CommitId;
element.actions = {
@@ -1218,13 +1303,21 @@
enabled: true,
},
};
- return element.reload();
+ await element.updateComplete;
+ // test
+ await element.reload();
});
test('revert change with plugin hook', async () => {
const newRevertMsg = 'Modified revert msg';
sinon
- .stub(element.$.confirmRevertDialog, 'modifyRevertMsg')
+ .stub(
+ queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
+ ),
+ 'modifyRevertMsg'
+ )
.callsFake(() => newRevertMsg);
element.change = {
...createChangeViewChange(),
@@ -1248,23 +1341,29 @@
);
sinon
.stub(
- element.$.confirmRevertDialog,
+ queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
+ ),
'populateRevertSubmissionMessage'
)
.callsFake(() => 'original msg');
- await flush();
- const revertButton = queryAndAssert(
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="revert"]'
+ ).click();
+ await element.updateComplete;
+ assert.equal(
+ queryAndAssert<GrConfirmRevertDialog>(element, '#confirmRevertDialog')
+ .message,
+ newRevertMsg
);
- tap(revertButton);
- await flush();
- assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
});
suite('revert change submitted together', () => {
let getChangesStub: sinon.SinonStub;
- setup(() => {
+ setup(async () => {
element.change = {
...createChangeViewChange(),
submission_id: '199 0' as ChangeSubmissionId,
@@ -1286,17 +1385,21 @@
},
])
);
+ await element.updateComplete;
});
test('confirm revert dialog shows both options', async () => {
- const revertButton = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="revert"]'
- );
- tap(revertButton);
- await flush();
+ ).click();
+ await element.updateComplete;
assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
- const confirmRevertDialog = element.$.confirmRevertDialog;
+ const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
+ );
+ await element.updateComplete;
const revertSingleChangeLabel = queryAndAssert<HTMLLabelElement>(
confirmRevertDialog,
'.revertSingleChange'
@@ -1324,12 +1427,12 @@
'23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
'\n';
assert.equal(confirmRevertDialog.message, expectedMsg);
- const radioInputs = queryAll(
+ const radioInputs = queryAll<HTMLInputElement>(
confirmRevertDialog,
'input[name="revertOptions"]'
);
- tap(radioInputs[0]);
- await flush();
+ radioInputs[0].click();
+ await element.updateComplete;
expectedMsg =
'Revert "random commit message"\n\nThis reverts ' +
'commit 2000.\n\nReason' +
@@ -1338,33 +1441,43 @@
});
test('submit fails if message is not edited', async () => {
- const revertButton = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="revert"]'
+ ).click();
+ const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
);
- const confirmRevertDialog = element.$.confirmRevertDialog;
- tap(revertButton);
const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
- await flush();
- const confirmButton = queryAndAssert(
- queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
+ queryAndAssert(
+ queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
+ ),
+ 'gr-dialog'
+ ),
'#confirm'
- );
- tap(confirmButton);
- await flush();
+ ).click();
+ await element.updateComplete;
assert.isTrue(confirmRevertDialog.showErrorMessage);
assert.isFalse(fireStub.called);
});
test('message modification is retained on switching', async () => {
- const revertButton = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="revert"]'
+ ).click();
+ await element.updateComplete;
+ const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
);
- const confirmRevertDialog = element.$.confirmRevertDialog;
- tap(revertButton);
- await flush();
- const radioInputs = queryAll(
+ await element.updateComplete;
+ const radioInputs = queryAll<HTMLInputElement>(
confirmRevertDialog,
'input[name="revertOptions"]'
);
@@ -1387,21 +1500,23 @@
const newRevertMsg = revertSubmissionMsg + 'random';
const newSingleChangeMsg = singleChangeMsg + 'random';
confirmRevertDialog.message = newRevertMsg;
- tap(radioInputs[0]);
- await flush();
+ await element.updateComplete;
+ radioInputs[0].click();
+ await element.updateComplete;
assert.equal(confirmRevertDialog.message, singleChangeMsg);
confirmRevertDialog.message = newSingleChangeMsg;
- tap(radioInputs[1]);
- await flush();
+ await element.updateComplete;
+ radioInputs[1].click();
+ await element.updateComplete;
assert.equal(confirmRevertDialog.message, newRevertMsg);
- tap(radioInputs[0]);
- await flush();
+ radioInputs[0].click();
+ await element.updateComplete;
assert.equal(confirmRevertDialog.message, newSingleChangeMsg);
});
});
suite('revert single change', () => {
- setup(() => {
+ setup(async () => {
element.change = {
...createChangeViewChange(),
submission_id: '199' as ChangeSubmissionId,
@@ -1417,35 +1532,45 @@
},
])
);
+ await element.updateComplete;
});
test('submit fails if message is not edited', async () => {
- const revertButton = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="revert"]'
+ ).click();
+ const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
);
- const confirmRevertDialog = element.$.confirmRevertDialog;
- tap(revertButton);
const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
- await flush();
- const confirmButton = queryAndAssert(
- queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
+ queryAndAssert(
+ queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
+ ),
+ 'gr-dialog'
+ ),
'#confirm'
- );
- tap(confirmButton);
- await flush();
+ ).click();
+ await element.updateComplete;
assert.isTrue(confirmRevertDialog.showErrorMessage);
assert.isFalse(fireStub.called);
});
test('confirm revert dialog shows no radio button', async () => {
- const revertButton = queryAndAssert(
+ queryAndAssert<GrButton>(
element,
'gr-button[data-action-key="revert"]'
+ ).click();
+ await element.updateComplete;
+ const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
);
- tap(revertButton);
- await flush();
- const confirmRevertDialog = element.$.confirmRevertDialog;
const radioInputs = queryAll(
confirmRevertDialog,
'input[name="revertOptions"]'
@@ -1458,12 +1583,18 @@
assert.equal(confirmRevertDialog.message, msg);
let editedMsg = msg + 'hello';
confirmRevertDialog.message += 'hello';
- const confirmButton = queryAndAssert(
- queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+ const confirmButton = queryAndAssert<GrButton>(
+ queryAndAssert(
+ queryAndAssert<GrConfirmRevertDialog>(
+ element,
+ '#confirmRevertDialog'
+ ),
+ 'gr-dialog'
+ ),
'#confirm'
);
- tap(confirmButton);
- await flush();
+ confirmButton.click();
+ await element.updateComplete;
// Contains generic template reason so doesn't submit
assert.isFalse(fireActionStub.called);
confirmRevertDialog.message = confirmRevertDialog.message.replace(
@@ -1471,8 +1602,8 @@
''
);
editedMsg = editedMsg.replace('<INSERT REASONING HERE>', '');
- tap(confirmButton);
- await flush();
+ confirmButton.click();
+ await element.updateComplete;
assert.equal(fireActionStub.getCall(0).args[0], '/revert');
assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
@@ -1481,7 +1612,7 @@
});
suite('mark change private', () => {
- setup(() => {
+ setup(async () => {
const privateAction = {
__key: 'private',
__type: 'change',
@@ -1501,34 +1632,41 @@
element.changeNum = 2 as NumericChangeId;
element.latestPatchNum = 2 as PatchSetNum;
- return element.reload();
+ await element.updateComplete;
+ await element.reload();
});
test(
'make sure the mark private change button is not outside of the ' +
'overflow menu',
async () => {
- await flush();
+ await element.updateComplete;
assert.isNotOk(query(element, '[data-action-key="private"]'));
}
);
test('private change', async () => {
- await flush();
+ await element.updateComplete;
assert.isOk(
- query(element.$.moreActions, 'span[data-id="private-change"]')
+ query(
+ queryAndAssert<GrDropdown>(element, '#moreActions'),
+ 'span[data-id="private-change"]'
+ )
);
element.setActionOverflow(ActionType.CHANGE, 'private', false);
- await flush();
+ await element.updateComplete;
assert.isOk(query(element, '[data-action-key="private"]'));
assert.isNotOk(
- query(element.$.moreActions, 'span[data-id="private-change"]')
+ query(
+ queryAndAssert<GrDropdown>(element, '#moreActions'),
+ 'span[data-id="private-change"]'
+ )
);
});
});
suite('unmark private change', () => {
- setup(() => {
+ setup(async () => {
const unmarkPrivateAction = {
__key: 'private.delete',
__type: 'change',
@@ -1548,28 +1686,35 @@
element.changeNum = 2 as NumericChangeId;
element.latestPatchNum = 2 as PatchSetNum;
- return element.reload();
+ await element.updateComplete;
+ await element.reload();
});
test(
'make sure the unmark private change button is not outside of the ' +
'overflow menu',
async () => {
- await flush();
+ await element.updateComplete;
assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
}
);
test('unmark the private change', async () => {
- await flush();
+ await element.updateComplete;
assert.isOk(
- query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+ query(
+ queryAndAssert<GrDropdown>(element, '#moreActions'),
+ 'span[data-id="private.delete-change"]'
+ )
);
element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
- await flush();
+ await element.updateComplete;
assert.isOk(query(element, '[data-action-key="private.delete"]'));
assert.isNotOk(
- query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+ query(
+ queryAndAssert<GrDropdown>(element, '#moreActions'),
+ 'span[data-id="private.delete-change"]'
+ )
);
});
});
@@ -1578,8 +1723,8 @@
let fireActionStub: sinon.SinonStub;
let deleteAction: ActionInfo;
- setup(() => {
- fireActionStub = sinon.stub(element, '_fireAction');
+ setup(async () => {
+ fireActionStub = sinon.stub(element, 'fireAction');
element.change = {
...createChangeViewChange(),
current_revision: 'abc1234' as CommitId,
@@ -1593,37 +1738,34 @@
element.actions = {
'/': deleteAction,
};
+ await element.updateComplete;
});
test('does not delete on action', () => {
- element._handleDeleteTap();
+ element.handleDeleteTap();
assert.isFalse(fireActionStub.called);
});
test('shows confirm dialog', async () => {
- element._handleDeleteTap();
+ element.handleDeleteTap();
assert.isFalse(
queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
);
- tap(
- queryAndAssert(
- queryAndAssert(element, '#confirmDeleteDialog'),
- 'gr-button[primary]'
- )
- );
- await flush();
+ queryAndAssert<GrButton>(
+ queryAndAssert(element, '#confirmDeleteDialog'),
+ 'gr-button[primary]'
+ ).click();
+ await element.updateComplete;
assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
});
test('hides delete confirm on cancel', async () => {
- element._handleDeleteTap();
- tap(
- queryAndAssert(
- queryAndAssert(element, '#confirmDeleteDialog'),
- 'gr-button:not([primary])'
- )
- );
- await flush();
+ element.handleDeleteTap();
+ queryAndAssert<GrButton>(
+ queryAndAssert(element, '#confirmDeleteDialog'),
+ 'gr-button:not([primary])'
+ ).click();
+ await element.updateComplete;
assert.isTrue(
queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
);
@@ -1649,7 +1791,7 @@
foo: ['-1', ' 0', '+1'],
},
};
- await flush();
+ await element.updateComplete;
});
test('added when can approve', () => {
@@ -1670,7 +1812,7 @@
// Assert approve button gets removed from list of buttons.
element.hideQuickApproveAction();
- await flush();
+ await element.updateComplete;
const approveButtonUpdated = query(
element,
"gr-button[data-action-key='review']"
@@ -1680,8 +1822,10 @@
});
test('is first in list of secondary actions', () => {
- const approveButton =
- element.$.secondaryActions.querySelector('gr-button');
+ const approveButton = queryAndAssert<HTMLElement>(
+ element,
+ '#secondaryActions'
+ ).querySelector('gr-button');
assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
});
@@ -1691,7 +1835,7 @@
status: ChangeStatus.MERGED,
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1713,7 +1857,7 @@
foo: [' 0', '+1'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1732,7 +1876,7 @@
bar: [],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1758,7 +1902,7 @@
'Code-Review': ['-1', ' 0', '+1'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1767,9 +1911,12 @@
});
test('approves when tapped', async () => {
- const fireActionStub = sinon.stub(element, '_fireAction');
- tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
- await flush();
+ const fireActionStub = sinon.stub(element, 'fireAction');
+ queryAndAssert<GrButton>(
+ element,
+ "gr-button[data-action-key='review']"
+ ).click();
+ await element.updateComplete;
assert.isTrue(fireActionStub.called);
assert.isTrue(fireActionStub.calledWith('/review'));
const payload = fireActionStub.lastCall.args[3];
@@ -1789,7 +1936,7 @@
bar: [' 0', '+1', '+2'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1819,7 +1966,7 @@
'Code-Review': [' 0', '+1', '+2'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = queryAndAssert(
element,
"gr-button[data-action-key='review']"
@@ -1845,7 +1992,7 @@
bar: [' 0', '+1', '+2'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = queryAndAssert(
element,
"gr-button[data-action-key='review']"
@@ -1871,7 +2018,7 @@
bar: [' 0', '+1'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1897,7 +2044,7 @@
bar: [' 0', '+1', '+2'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = queryAndAssert(
element,
"gr-button[data-action-key='review']"
@@ -1923,8 +2070,8 @@
'Code-Review': [' 0', '+1', '+2'],
},
};
- await flush();
- const approveButton = queryAndAssert(
+ await element.updateComplete;
+ const approveButton = queryAndAssert<GrButton>(
element,
"gr-button[data-action-key='review']"
);
@@ -1956,7 +2103,7 @@
'Code-Review': [' 0', '+1', '+2'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1983,7 +2130,7 @@
'Code-Review': [' 0', '+1', '+2'],
},
};
- await flush();
+ await element.updateComplete;
const approveButton = query(
element,
"gr-button[data-action-key='review']"
@@ -1996,8 +2143,8 @@
const handler = sinon.stub();
element.addEventListener('download-tap', handler);
assert.ok(element.revisionActions.download);
- element._handleDownloadTap();
- await flush();
+ element.handleDownloadTap();
+ await element.updateComplete;
assert.isTrue(handler.called);
});
@@ -2010,26 +2157,26 @@
assert.isFalse(reloadStub.called);
});
- test('_toSentenceCase', () => {
- assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
- assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
- assert.equal(element._toSentenceCase('b'), 'B');
- assert.equal(element._toSentenceCase(''), '');
- assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+ test('toSentenceCase', () => {
+ assert.equal(element.toSentenceCase('blah blah'), 'Blah blah');
+ assert.equal(element.toSentenceCase('BLAH BLAH'), 'Blah blah');
+ assert.equal(element.toSentenceCase('b'), 'B');
+ assert.equal(element.toSentenceCase(''), '');
+ assert.equal(element.toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
});
suite('setActionOverflow', () => {
test('move action from overflow', async () => {
assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
assert.strictEqual(
- element.$.moreActions.items![0].id,
+ queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
'cherrypick-revision'
);
element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
- await flush();
+ await element.updateComplete;
assert.isOk(query(element, '[data-action-key="cherrypick"]'));
assert.notEqual(
- element.$.moreActions.items![0].id,
+ queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
'cherrypick-revision'
);
});
@@ -2037,15 +2184,15 @@
test('move action to overflow', async () => {
assert.isOk(query(element, '[data-action-key="submit"]'));
element.setActionOverflow(ActionType.REVISION, 'submit', true);
- await flush();
+ await element.updateComplete;
assert.isNotOk(query(element, '[data-action-key="submit"]'));
assert.strictEqual(
- element.$.moreActions.items![3].id,
+ queryAndAssert<GrDropdown>(element, '#moreActions').items![2].id,
'submit-revision'
);
});
- suite('_waitForChangeReachable', () => {
+ suite('waitForChangeReachable', () => {
let clock: SinonFakeTimers;
setup(() => {
clock = sinon.useFakeTimers();
@@ -2066,13 +2213,13 @@
const tickAndFlush = async (repetitions: number) => {
for (let i = 1; i <= repetitions; i++) {
clock.tick(1000);
- await flush();
+ await element.updateComplete;
}
};
test('succeed', async () => {
stubRestApi('getChange').callsFake(makeGetChange(5));
- const promise = element._waitForChangeReachable(
+ const promise = element.waitForChangeReachable(
123 as NumericChangeId
);
tickAndFlush(5);
@@ -2082,7 +2229,7 @@
test('fail', async () => {
stubRestApi('getChange').callsFake(makeGetChange(6));
- const promise = element._waitForChangeReachable(
+ const promise = element.waitForChangeReachable(
123 as NumericChangeId
);
tickAndFlush(6);
@@ -2092,14 +2239,14 @@
});
});
- suite('_send', () => {
+ suite('send', () => {
let cleanup: sinon.SinonStub;
const payload = {foo: 'bar'};
let onShowError: sinon.SinonStub;
let onShowAlert: sinon.SinonStub;
let getResponseObjectStub: sinon.SinonStub;
- setup(() => {
+ setup(async () => {
cleanup = sinon.stub();
element.changeNum = 42 as NumericChangeId;
element.latestPatchNum = 12 as PatchSetNum;
@@ -2109,6 +2256,7 @@
messages: createChangeMessages(1),
};
element.change._number = 42 as NumericChangeId;
+ await element.updateComplete;
onShowError = sinon.stub();
element.addEventListener('show-error', onShowError);
@@ -2135,7 +2283,7 @@
});
test('change action', async () => {
- await element._send(
+ await element.send(
HttpMethod.DELETE,
payload,
'/endpoint',
@@ -2157,7 +2305,7 @@
});
suite('show revert submission dialog', () => {
- setup(() => {
+ setup(async () => {
element.change!.submission_id = '199' as ChangeSubmissionId;
element.change!.current_revision = '2000' as CommitId;
stubRestApi('getChanges').returns(
@@ -2176,6 +2324,7 @@
},
])
);
+ await element.updateComplete;
});
});
@@ -2192,7 +2341,7 @@
});
test('revert submission single change', async () => {
- await element._send(
+ await element.send(
HttpMethod.POST,
{message: 'Revert submission'},
'/revert_submission',
@@ -2200,7 +2349,7 @@
cleanup,
{} as UIActionInfo
);
- await element._handleResponse(
+ await element.handleResponse(
{
__key: 'revert_submission',
__type: ActionType.CHANGE,
@@ -2224,7 +2373,7 @@
],
})
);
- showActionDialogStub = sinon.stub(element, '_showActionDialog');
+ showActionDialogStub = sinon.stub(element, 'showActionDialog');
navigateToSearchQueryStub = sinon.stub(
GerritNav,
'navigateToSearchQuery'
@@ -2232,7 +2381,7 @@
});
test('revert submission multiple change', async () => {
- await element._send(
+ await element.send(
HttpMethod.POST,
{message: 'Revert submission'},
'/revert_submission',
@@ -2240,7 +2389,7 @@
cleanup,
{} as UIActionInfo
);
- await element._handleResponse(
+ await element.handleResponse(
{
__key: 'revert_submission',
__type: ActionType.CHANGE,
@@ -2254,7 +2403,7 @@
});
test('revision action', async () => {
- await element._send(
+ await element.send(
HttpMethod.DELETE,
payload,
'/endpoint',
@@ -2285,7 +2434,7 @@
const sendStub = stubRestApi('executeChangeAction');
return element
- ._send(
+ .send(
HttpMethod.DELETE,
payload,
'/endpoint',
@@ -2317,10 +2466,10 @@
return Promise.resolve(undefined);
}
);
- const handleErrorStub = sinon.stub(element, '_handleResponseError');
+ const handleErrorStub = sinon.stub(element, 'handleResponseError');
return element
- ._send(
+ .send(
HttpMethod.DELETE,
payload,
'/endpoint',
@@ -2338,12 +2487,12 @@
});
});
- test('_handleAction reports', () => {
- sinon.stub(element, '_fireAction');
- sinon.stub(element, '_handleChangeAction');
+ test('handleAction reports', () => {
+ sinon.stub(element, 'fireAction');
+ sinon.stub(element, 'handleChangeAction');
const reportStub = stubReporting('reportInteraction');
- element._handleAction(ActionType.CHANGE, 'key');
+ element.handleAction(ActionType.CHANGE, 'key');
assert.isTrue(reportStub.called);
assert.equal(reportStub.lastCall.args[0], 'change-key');
});
@@ -2354,7 +2503,7 @@
let changeRevisionActions: ActionNameToActionInfoMap = {};
- setup(() => {
+ setup(async () => {
stubRestApi('getChangeRevisionActions').returns(
Promise.resolve(changeRevisionActions)
);
@@ -2364,7 +2513,9 @@
.stub(getPluginLoader(), 'awaitPluginsLoaded')
.returns(Promise.resolve());
- element = basicFixture.instantiate();
+ element = await fixture<GrChangeActions>(html`
+ <gr-change-actions></gr-change-actions>
+ `);
// getChangeRevisionActions is not called without
// set the following properties
element.change = createChangeViewChange();
@@ -2372,33 +2523,23 @@
element.latestPatchNum = 2 as PatchSetNum;
stubRestApi('getRepoBranches').returns(Promise.resolve([]));
- return element.reload();
+ await element.updateComplete;
+ await element.reload();
});
test('confirmSubmitDialog and confirmRebase properties are changed', () => {
changeRevisionActions = {};
element.reload();
- assert.strictEqual(element.$.confirmSubmitDialog.action, null);
- assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
- });
-
- test('_computeRebaseOnCurrent', () => {
- const rebaseAction = {
- enabled: true,
- label: 'Rebase',
- method: HttpMethod.POST,
- title: 'Rebase onto tip of branch or parent change',
- };
-
- // When rebase is enabled initially, rebaseOnCurrent should be set to
- // true.
- assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
- rebaseAction.enabled = false;
-
- // When rebase is not enabled initially, rebaseOnCurrent should be set to
- // false.
- assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+ assert.strictEqual(
+ queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+ .action,
+ null
+ );
+ assert.strictEqual(
+ queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase')
+ .rebaseOnCurrent,
+ null
+ );
});
});
});
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 e8448b7..87caddf 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
@@ -2635,6 +2635,12 @@
createTitle(shortcutName: Shortcut, section: ShortcutSection) {
return this.shortcuts.createTitle(shortcutName, section);
}
+
+ _handleRevisionActionsChanged(
+ e: CustomEvent<{value: ActionNameToActionInfoMap}>
+ ) {
+ this._currentRevisionActions = e.detail.value;
+ }
}
declare global {
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 72021e5..5d4db56 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
@@ -363,7 +363,7 @@
disable-edit="[[disableEdit]]"
has-parent="[[hasParent]]"
actions="[[_change.actions]]"
- revision-actions="{{_currentRevisionActions}}"
+ revision-actions="[[_currentRevisionActions]]"
account="[[_account]]"
change-num="[[_changeNum]]"
change-status="[[_change.status]]"
@@ -379,6 +379,7 @@
on-stop-edit-tap="_handleStopEditTap"
on-download-tap="_handleOpenDownloadDialog"
on-included-tap="_handleOpenIncludedInDialog"
+ on-revision-actions-changed="_handleRevisionActionsChanged"
></gr-change-actions>
</div>
<!-- end commit actions -->
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 f4e5835..eb6d071 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
@@ -277,8 +277,6 @@
private readonly getChecksModel = resolve(this, checksModelToken);
- private readonly flagService = getAppContext().flagsService;
-
private readonly reporting = getAppContext().reportingService;
private readonly flags = getAppContext().flagsService;
@@ -494,9 +492,6 @@
this.checksChanged([]);
}
- const experiment = KnownExperimentId.CHECK_RESULTS_IN_DIFFS;
- if (!this.flagService.isEnabled(experiment)) return;
-
const path = this.path;
const patchNum = this.patchRange?.patchNum;
if (!path || !patchNum || patchNum === EditPatchSetNum) return;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 8a01ef7..64548ac 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -1,39 +1,24 @@
/**
* @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
*/
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-endpoint-decorator_html';
+import {html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
import {
getPluginEndpoints,
ModuleInfo,
} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
import {PluginApi} from '../../../api/plugin';
import {HookApi, PluginElement} from '../../../api/hook';
import {getAppContext} from '../../../services/app-context';
+import {assertIsDefined} from '../../../utils/common-util';
const INIT_PROPERTIES_TIMEOUT_MS = 10000;
@customElement('gr-endpoint-decorator')
-export class GrEndpointDecorator extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
-
+export class GrEndpointDecorator extends LitElement {
/**
* If set, then this endpoint only invokes callbacks registered by the target
* plugin. For example this is used for the `check-result-expanded` endpoint.
@@ -43,39 +28,51 @@
@property({type: String})
targetPlugin?: string;
+ /** Required. */
@property({type: String})
- name!: string;
+ name?: string;
- @property({type: Object})
- _domHooks = new Map<PluginElement, HookApi<PluginElement>>();
+ private readonly domHooks = new Map<PluginElement, HookApi<PluginElement>>();
- @property({type: Object})
- _initializedPlugins = new Map<string, boolean>();
-
- /**
- * This is the callback that the plugin endpoint manager should be calling
- * when a new element is registered for this endpoint. It points to
- * _initModule().
- */
- _endpointCallBack: (info: ModuleInfo) => void = () => {};
+ private readonly initializedPlugins = new Map<string, boolean>();
private readonly reporting = getAppContext().reportingService;
+ override render() {
+ return html`<slot></slot>`;
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ assertIsDefined(this.name);
+ getPluginEndpoints().onNewEndpoint(this.name, this.initModule);
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ assertIsDefined(this.name);
+ const modules = getPluginEndpoints().getDetails(this.name);
+ for (const module of modules) {
+ this.initModule(module);
+ }
+ });
+ }
+
override disconnectedCallback() {
- for (const [el, domHook] of this._domHooks) {
+ for (const [el, domHook] of this.domHooks) {
domHook.handleInstanceDetached(el);
}
- getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+ assertIsDefined(this.name);
+ getPluginEndpoints().onDetachedEndpoint(this.name, this.initModule);
super.disconnectedCallback();
}
- _initDecoration(
+ private initDecoration(
name: string,
plugin: PluginApi,
slot?: string
): Promise<HTMLElement> {
const el = document.createElement(name) as PluginElement;
- return this._initProperties(
+ return this.initProperties(
el,
plugin,
// The direct children are slotted into <slot>, so this is identical to
@@ -88,13 +85,16 @@
if (slot && slotEl?.parentNode) {
slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
} else {
- this._appendChild(el);
+ this.appendChild(el);
}
return el;
});
}
- _initReplacement(name: string, plugin: PluginApi): Promise<HTMLElement> {
+ private initReplacement(
+ name: string,
+ plugin: PluginApi
+ ): Promise<HTMLElement> {
// The direct children are slotted into <slot>, so they are identical to
// this.shadowRoot.querySelector('slot').assignedElements().
const directChildren = [...this.childNodes];
@@ -104,16 +104,16 @@
.filter(node => node.nodeName !== 'SLOT')
.forEach(node => node.remove());
const el = document.createElement(name);
- return this._initProperties(el, plugin).then((el: HTMLElement) =>
- this._appendChild(el)
+ return this.initProperties(el, plugin).then((el: HTMLElement) =>
+ this.appendChild(el)
);
}
- _getEndpointParams() {
+ private getEndpointParams() {
return Array.from(this.querySelectorAll('gr-endpoint-param'));
}
- _initProperties(
+ private initProperties(
el: PluginElement,
plugin: PluginApi,
content?: Element | null
@@ -128,7 +128,7 @@
if (content) {
el.content = content as HTMLElement;
}
- const expectProperties = this._getEndpointParams().map(paramEl => {
+ const expectProperties = this.getEndpointParams().map(paramEl => {
const helper = plugin.attributeHelper(paramEl);
// TODO: this should be replaced by accessing the property directly
const paramName = paramEl.getAttribute('name');
@@ -170,53 +170,40 @@
});
}
- _appendChild(el: HTMLElement): HTMLElement {
- if (!this.root) throw Error('plugin endpoint decorator missing root');
- return this.root.appendChild(el);
- }
-
- _initModule({moduleName, plugin, type, domHook, slot}: ModuleInfo) {
+ private readonly initModule = ({
+ moduleName,
+ plugin,
+ type,
+ domHook,
+ slot,
+ }: ModuleInfo) => {
const name = plugin.getPluginName() + '.' + moduleName;
if (this.targetPlugin) {
if (this.targetPlugin !== plugin.getPluginName()) return;
}
- if (this._initializedPlugins.get(name)) {
+ if (this.initializedPlugins.get(name)) {
return;
}
let initPromise;
switch (type) {
case 'decorate':
- initPromise = this._initDecoration(moduleName, plugin, slot);
+ initPromise = this.initDecoration(moduleName, plugin, slot);
break;
case 'replace':
- initPromise = this._initReplacement(moduleName, plugin);
+ initPromise = this.initReplacement(moduleName, plugin);
break;
}
if (!initPromise) {
throw Error(`unknown endpoint type ${type} used by plugin ${name}`);
}
- this._initializedPlugins.set(name, true);
+ this.initializedPlugins.set(name, true);
initPromise.then(el => {
if (domHook) {
domHook.handleInstanceAttached(el);
- this._domHooks.set(el, domHook);
+ this.domHooks.set(el, domHook);
}
});
- }
-
- override ready() {
- super.ready();
- if (!this.name) return;
- this._endpointCallBack = (info: ModuleInfo) => this._initModule(info);
- getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
- getPluginLoader()
- .awaitPluginsLoaded()
- .then(() =>
- getPluginEndpoints()
- .getDetails(this.name)
- .forEach(this._initModule, this)
- );
- }
+ };
}
declare global {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
+++ /dev/null
@@ -1,19 +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` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
index f5096ea..d7acc61 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -1,60 +1,75 @@
/**
* @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
*/
-
import '../../../test/common-test-setup-karma';
import './gr-endpoint-decorator';
import '../gr-endpoint-param/gr-endpoint-param';
import '../gr-endpoint-slot/gr-endpoint-slot';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {resetPlugins} from '../../../test/test-utils';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {
+ mockPromise,
+ queryAndAssert,
+ resetPlugins,
+} from '../../../test/test-utils';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {GrEndpointDecorator} from './gr-endpoint-decorator';
import {PluginApi} from '../../../api/plugin';
import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param';
-const basicFixture = fixtureFromTemplate(
- html`<div>
- <gr-endpoint-decorator name="first">
- <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
- <p>
- <span>test slot</span>
- <gr-endpoint-slot name="test"></gr-endpoint-slot>
- </p>
- </gr-endpoint-decorator>
- <gr-endpoint-decorator name="second">
- <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
- </gr-endpoint-decorator>
- <gr-endpoint-decorator name="banana">
- <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
- </gr-endpoint-decorator>
- </div>`
-);
-
suite('gr-endpoint-decorator', () => {
- let container: GrEndpointDecorator;
+ let container: HTMLElement;
let plugin: PluginApi;
let decorationHook: any;
let decorationHookWithSlot: any;
let replacementHook: any;
+ let first: GrEndpointDecorator;
+ let second: GrEndpointDecorator;
+ let banana: GrEndpointDecorator;
setup(async () => {
resetPlugins();
- container = basicFixture.instantiate() as GrEndpointDecorator;
+ container = await fixture(
+ html`<div>
+ <gr-endpoint-decorator name="first">
+ <gr-endpoint-param
+ name="first-param"
+ .value=${'barbar'}
+ ></gr-endpoint-param>
+ <p>
+ <span>test slot</span>
+ <gr-endpoint-slot name="test"></gr-endpoint-slot>
+ </p>
+ </gr-endpoint-decorator>
+ <gr-endpoint-decorator name="second">
+ <gr-endpoint-param
+ name="second-param"
+ .value=${'foofoo'}
+ ></gr-endpoint-param>
+ </gr-endpoint-decorator>
+ <gr-endpoint-decorator name="banana">
+ <gr-endpoint-param
+ name="banana-param"
+ .value=${'yes'}
+ ></gr-endpoint-param>
+ </gr-endpoint-decorator>
+ </div>`
+ );
+ first = queryAndAssert<GrEndpointDecorator>(
+ container,
+ 'gr-endpoint-decorator[name="first"]'
+ );
+ second = queryAndAssert<GrEndpointDecorator>(
+ container,
+ 'gr-endpoint-decorator[name="second"]'
+ );
+ banana = queryAndAssert<GrEndpointDecorator>(
+ container,
+ 'gr-endpoint-decorator[name="banana"]'
+ );
+
window.Gerrit.install(
p => {
plugin = p;
@@ -64,18 +79,33 @@
);
// Decoration
decorationHook = plugin.registerCustomComponent('first', 'some-module');
+ const decorationHookPromise = mockPromise();
+ decorationHook.onAttached(() => decorationHookPromise.resolve());
+
+ // Decoration with slot
decorationHookWithSlot = plugin.registerCustomComponent(
'first',
'some-module-2',
{slot: 'test'}
);
+ const decorationHookSlotPromise = mockPromise();
+ decorationHookWithSlot.onAttached(() =>
+ decorationHookSlotPromise.resolve()
+ );
+
// Replacement
replacementHook = plugin.registerCustomComponent('second', 'other-module', {
replace: true,
});
+ const replacementHookPromise = mockPromise();
+ replacementHook.onAttached(() => replacementHookPromise.resolve());
+
// Mimic all plugins loaded.
getPluginLoader().loadPlugins([]);
- await flush();
+
+ await decorationHookPromise;
+ await decorationHookSlotPromise;
+ await replacementHookPromise;
});
teardown(() => {
@@ -89,17 +119,15 @@
assert.equal(endpoints.length, 3);
});
- test('decoration', () => {
- const element = container.querySelector(
- 'gr-endpoint-decorator[name="first"]'
- ) as GrEndpointDecorator;
- const modules = Array.from(element.root!.children).filter(
+ test('first decoration', () => {
+ const element = first;
+ const modules = Array.from(element.children).filter(
element => element.nodeName === 'SOME-MODULE'
);
assert.equal(modules.length, 1);
const [module] = modules;
assert.isOk(module);
- assert.equal((module as any)['someparam'], 'barbar');
+ assert.equal((module as any)['first-param'], 'barbar');
return decorationHook
.getLastAttached()
.then((element: any) => {
@@ -112,14 +140,12 @@
});
test('decoration with slot', () => {
- const element = container.querySelector(
- 'gr-endpoint-decorator[name="first"]'
- ) as GrEndpointDecorator;
+ const element = first;
const modules = [...element.querySelectorAll('some-module-2')];
assert.equal(modules.length, 1);
const [module] = modules;
assert.isOk(module);
- assert.equal((module as any)['someparam'], 'barbar');
+ assert.equal((module as any)['first-param'], 'barbar');
return decorationHookWithSlot
.getLastAttached()
.then((element: any) => {
@@ -132,14 +158,12 @@
});
test('replacement', () => {
- const element = container.querySelector(
- 'gr-endpoint-decorator[name="second"]'
- ) as GrEndpointDecorator;
- const module = Array.from(element.root!.children).find(
+ const element = second;
+ const module = Array.from(element.children).find(
element => element.nodeName === 'OTHER-MODULE'
);
assert.isOk(module);
- assert.equal((module as any)['someparam'], 'foofoo');
+ assert.equal((module as any)['second-param'], 'foofoo');
return replacementHook
.getLastAttached()
.then((element: any) => {
@@ -152,73 +176,92 @@
});
test('late registration', async () => {
- plugin.registerCustomComponent('banana', 'noob-noob');
- await flush();
- const element = container.querySelector(
- 'gr-endpoint-decorator[name="banana"]'
- ) as GrEndpointDecorator;
- const module = Array.from(element.root!.children).find(
+ const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+ const bananaHookPromise = mockPromise();
+ bananaHook.onAttached(() => bananaHookPromise.resolve());
+ await bananaHookPromise;
+
+ const element = banana;
+ const module = Array.from(element.children).find(
element => element.nodeName === 'NOOB-NOOB'
);
assert.isOk(module);
});
test('two modules', async () => {
- plugin.registerCustomComponent('banana', 'mod-one');
- plugin.registerCustomComponent('banana', 'mod-two');
- await flush();
- const element = container.querySelector(
- 'gr-endpoint-decorator[name="banana"]'
- ) as GrEndpointDecorator;
- const module1 = Array.from(element.root!.children).find(
+ const bananaHook1 = plugin.registerCustomComponent('banana', 'mod-one');
+ const bananaHookPromise1 = mockPromise();
+ bananaHook1.onAttached(() => bananaHookPromise1.resolve());
+ await bananaHookPromise1;
+
+ const bananaHook = plugin.registerCustomComponent('banana', 'mod-two');
+ const bananaHookPromise2 = mockPromise();
+ bananaHook.onAttached(() => bananaHookPromise2.resolve());
+ await bananaHookPromise2;
+
+ const element = banana;
+ const module1 = Array.from(element.children).find(
element => element.nodeName === 'MOD-ONE'
);
assert.isOk(module1);
- const module2 = Array.from(element.root!.children).find(
+ const module2 = Array.from(element.children).find(
element => element.nodeName === 'MOD-TWO'
);
assert.isOk(module2);
});
test('late param setup', async () => {
- const element = container.querySelector(
- 'gr-endpoint-decorator[name="banana"]'
- ) as GrEndpointDecorator;
- const param = element.querySelector('gr-endpoint-param') as GrEndpointParam;
+ let element = banana;
+ const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
param['value'] = undefined;
- plugin.registerCustomComponent('banana', 'noob-noob');
- await flush();
- let module = Array.from(element.root!.children).find(
+ await param.updateComplete;
+
+ const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+ const bananaHookPromise = mockPromise();
+ bananaHook.onAttached(() => bananaHookPromise.resolve());
+
+ element = queryAndAssert<GrEndpointDecorator>(
+ container,
+ 'gr-endpoint-decorator[name="banana"]'
+ );
+ let module = Array.from(element.children).find(
element => element.nodeName === 'NOOB-NOOB'
);
// Module waits for param to be defined.
assert.isNotOk(module);
const value = {abc: 'def'};
param.value = value;
+ await param.updateComplete;
+ await bananaHookPromise;
- await flush();
- module = Array.from(element.root!.children).find(
+ module = Array.from(element.children).find(
element => element.nodeName === 'NOOB-NOOB'
);
assert.isOk(module);
- assert.strictEqual((module as any)['someParam'], value);
+ assert.strictEqual((module as any)['banana-param'], value);
});
test('param is bound', async () => {
- const element = container.querySelector(
- 'gr-endpoint-decorator[name="banana"]'
- ) as GrEndpointDecorator;
- const param = element.querySelector('gr-endpoint-param') as GrEndpointParam;
+ const element = banana;
+ const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
const value1 = {abc: 'def'};
const value2 = {def: 'abc'};
param.value = value1;
- plugin.registerCustomComponent('banana', 'noob-noob');
- await flush();
- const module = Array.from(element.root!.children).find(
+ await param.updateComplete;
+
+ const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+ const bananaHookPromise = mockPromise();
+ bananaHook.onAttached(() => bananaHookPromise.resolve());
+ await bananaHookPromise;
+
+ const module = Array.from(element.children).find(
element => element.nodeName === 'NOOB-NOOB'
);
- assert.strictEqual((module as any)['someParam'], value1);
+ assert.isOk(module);
+ assert.strictEqual((module as any)['banana-param'], value1);
+
param.value = value2;
- assert.strictEqual((module as any)['someParam'], value2);
+ await param.updateComplete;
+ assert.strictEqual((module as any)['banana-param'], value2);
});
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index ee89c86..5a00c37 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -1,21 +1,10 @@
/**
* @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
*/
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
declare global {
interface HTMLElementTagNameMap {
@@ -24,26 +13,18 @@
}
@customElement('gr-endpoint-param')
-export class GrEndpointParam extends PolymerElement {
- @property({type: String, reflectToAttribute: true})
+export class GrEndpointParam extends LitElement {
+ @property({type: String, reflect: true})
name = '';
- @property({
- type: Object,
- notify: true,
- observer: '_valueChanged',
- })
+ @property({type: Object})
value?: unknown;
- _valueChanged(value: unknown) {
- /* In polymer 2 the following change was made:
- "Property change notifications (property-changed events) aren't fired when
- the value changes as a result of a binding from the host"
- (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
- To workaround this problem, we fire the event from the observer.
- In some cases this fire the event twice, but our code is
- ready for it.
- */
- this.dispatchEvent(new CustomEvent('value-changed', {detail: {value}}));
+ override willUpdate(changedProperties: PropertyValues) {
+ if (changedProperties.has('value')) {
+ this.dispatchEvent(
+ new CustomEvent('value-changed', {detail: {value: this.value}})
+ );
+ }
}
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
index f15b046..d6d1866 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
@@ -1,21 +1,10 @@
/**
* @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
*/
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
declare global {
interface HTMLElementTagNameMap {
@@ -28,7 +17,7 @@
* the registered element should appear inside of the endpoint.
*/
@customElement('gr-endpoint-slot')
-export class GrEndpointSlot extends PolymerElement {
+export class GrEndpointSlot extends LitElement {
@property({type: String})
name!: string;
}
@@ -40,6 +29,6 @@
* This should help catch errors when you assign an element without
* name to GrEndpointSlot type.
*/
-export interface GrEndpointSlot extends PolymerElement {
+export interface GrEndpointSlot extends LitElement {
name: string;
}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index c8597bf..ca3f11d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -16,12 +16,6 @@
*/
import '@polymer/iron-input/iron-input';
import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-page-nav-styles';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../gr-change-table-editor/gr-change-table-editor';
import '../../shared/gr-button/gr-button';
@@ -39,10 +33,7 @@
import '../gr-menu-editor/gr-menu-editor';
import '../gr-ssh-editor/gr-ssh-editor';
import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-settings-view_html';
import {getDocsBaseUrl} from '../../../utils/url-util';
-import {customElement, property, observe} from '@polymer/decorators';
import {AppElementParams} from '../../gr-app-types';
import {GrAccountInfo} from '../gr-account-info/gr-account-info';
import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
@@ -66,24 +57,16 @@
} from '../../../constants/constants';
import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
import {windowLocationReload} from '../../../utils/dom-util';
-import {ValueChangedEvent} from '../../../types/events';
-
-const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
- 'changes_per_page',
- 'date_format',
- 'time_format',
- 'email_strategy',
- 'diff_view',
- 'publish_comments_on_push',
- 'disable_keyboard_shortcuts',
- 'disable_token_highlighting',
- 'work_in_progress_by_default',
- 'default_base_for_merges',
- 'signed_off_by',
- 'email_format',
- 'size_bar_in_change_table',
- 'relative_date_in_change_table',
-];
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
const GERRIT_DOCS_BASE_URL =
'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -98,39 +81,8 @@
LocalPrefsToPrefs,
}
-export interface GrSettingsView {
- $: {
- accountInfo: GrAccountInfo;
- watchedProjectsEditor: GrWatchedProjectsEditor;
- groupList: GrGroupList;
- identities: GrIdentities;
- diffPrefs: GrDiffPreferences;
- sshEditor: GrSshEditor;
- gpgEditor: GrGpgEditor;
- emailEditor: GrEmailEditor;
- insertSignedOff: HTMLInputElement;
- workInProgressByDefault: HTMLInputElement;
- showSizeBarsInFileList: HTMLInputElement;
- publishCommentsOnPush: HTMLInputElement;
- disableKeyboardShortcuts: HTMLInputElement;
- disableTokenHighlighting: HTMLInputElement;
- relativeDateInChangeTable: HTMLInputElement;
- changesPerPageSelect: HTMLInputElement;
- dateTimeFormatSelect: HTMLInputElement;
- timeFormatSelect: HTMLInputElement;
- emailNotificationsSelect: HTMLInputElement;
- emailFormatSelect: HTMLInputElement;
- defaultBaseForMergesSelect: HTMLInputElement;
- diffViewSelect: HTMLInputElement;
- };
-}
-
@customElement('gr-settings-view')
-export class GrSettingsView extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
-
+export class GrSettingsView extends LitElement {
/**
* Fired when the title of the page should change.
*
@@ -143,66 +95,106 @@
* @event show-alert
*/
- @property({type: Object})
- prefs: PreferencesInput = {};
+ @query('#accountInfo', true) accountInfo!: GrAccountInfo;
- @property({type: Object})
- params?: AppElementParams;
+ @query('#watchedProjectsEditor', true)
+ watchedProjectsEditor!: GrWatchedProjectsEditor;
- @property({type: Boolean})
- _accountInfoChanged = false;
+ @query('#groupList', true) groupList!: GrGroupList;
- @property({type: Object})
- _localPrefs: PreferencesInput = {};
+ @query('#identities', true) identities!: GrIdentities;
- @property({type: Array})
- _localChangeTableColumns: string[] = [];
+ @query('#diffPrefs') diffPrefs!: GrDiffPreferences;
- @property({type: Boolean})
- _loading = true;
+ @query('#sshEditor') sshEditor?: GrSshEditor;
- @property({type: Boolean})
- _changeTableChanged = false;
+ @query('#gpgEditor') gpgEditor?: GrGpgEditor;
- @property({type: Boolean})
- _prefsChanged = false;
+ @query('#emailEditor', true) emailEditor!: GrEmailEditor;
- @property({type: Boolean})
- _diffPrefsChanged = false;
+ @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
- @property({type: Boolean})
- _watchedProjectsChanged = false;
+ @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
- @property({type: Boolean})
- _keysChanged = false;
+ @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
- @property({type: Boolean})
- _gpgKeysChanged = false;
+ @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
- @property({type: String})
- _newEmail?: string;
+ @query('#disableKeyboardShortcuts')
+ disableKeyboardShortcuts!: HTMLInputElement;
- @property({type: Boolean})
- _addingEmail = false;
+ @query('#disableTokenHighlighting')
+ disableTokenHighlighting!: HTMLInputElement;
- @property({type: String})
- _lastSentVerificationEmail?: string | null = null;
+ @query('#relativeDateInChangeTable')
+ relativeDateInChangeTable!: HTMLInputElement;
- @property({type: Object})
- _serverConfig?: ServerInfo;
+ @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
- @property({type: String})
- _docsBaseUrl?: string | null;
+ @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
- @property({type: Boolean})
- _emailsChanged = false;
+ @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
- @property({type: Boolean})
- _showNumber?: boolean;
+ @query('#emailNotificationsSelect')
+ emailNotificationsSelect!: HTMLInputElement;
- @property({type: Boolean})
- _isDark = false;
+ @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
+ @query('#defaultBaseForMergesSelect')
+ defaultBaseForMergesSelect!: HTMLInputElement;
+
+ @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
+
+ @state() prefs: PreferencesInput = {};
+
+ @property({type: Object}) params?: AppElementParams;
+
+ @state() private accountInfoChanged = false;
+
+ @state() private localPrefs: PreferencesInput = {};
+
+ // private but used in test
+ @state() localChangeTableColumns: string[] = [];
+
+ @state() private loading = true;
+
+ @state() private changeTableChanged = false;
+
+ // private but used in test
+ @state() prefsChanged = false;
+
+ @state() private diffPrefsChanged = false;
+
+ @state() private watchedProjectsChanged = false;
+
+ @state() private keysChanged = false;
+
+ @state() private gpgKeysChanged = false;
+
+ // private but used in test
+ @state() newEmail?: string;
+
+ // private but used in test
+ @state() addingEmail = false;
+
+ // private but used in test
+ @state() lastSentVerificationEmail?: string | null = null;
+
+ // private but used in test
+ @state() serverConfig?: ServerInfo;
+
+ // private but used in test
+ @state() docsBaseUrl?: string | null;
+
+ @state() private emailsChanged = false;
+
+ // private but used in test
+ @state() showNumber?: boolean;
+
+ // private but used in test
+ @state() isDark = false;
+
+ // private but used in test
public _testOnly_loadingPromise?: Promise<void>;
private readonly restApiService = getAppContext().restApiService;
@@ -213,14 +205,16 @@
// we need to manually calling scrollIntoView when hash changed
window.addEventListener('location-change', this.handleLocationChange);
fireTitleChange(this, 'Settings');
+ }
- this._isDark = !!window.localStorage.getItem('dark-theme');
+ override firstUpdated() {
+ this.isDark = !!window.localStorage.getItem('dark-theme');
const promises: Array<Promise<unknown>> = [
- this.$.accountInfo.loadData(),
- this.$.watchedProjectsEditor.loadData(),
- this.$.groupList.loadData(),
- this.$.identities.loadData(),
+ this.accountInfo.loadData(),
+ this.watchedProjectsEditor.loadData(),
+ this.groupList.loadData(),
+ this.identities.loadData(),
];
// TODO(dhruvsri): move this to the service
@@ -230,9 +224,9 @@
throw new Error('getPreferences returned undefined');
}
this.prefs = prefs;
- this._showNumber = !!prefs.legacycid_in_change_table;
- this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
- this._localChangeTableColumns =
+ this.showNumber = !!prefs.legacycid_in_change_table;
+ this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+ this.localChangeTableColumns =
prefs.change_table.length === 0
? columnNames
: prefs.change_table.map(column =>
@@ -243,24 +237,20 @@
promises.push(
this.restApiService.getConfig().then(config => {
- this._serverConfig = config;
+ this.serverConfig = config;
const configPromises: Array<Promise<void>> = [];
- if (this._serverConfig && this._serverConfig.sshd) {
- configPromises.push(this.$.sshEditor.loadData());
+ if (this.serverConfig?.sshd && this.sshEditor) {
+ configPromises.push(this.sshEditor.loadData());
}
- if (
- this._serverConfig &&
- this._serverConfig.receive &&
- this._serverConfig.receive.enable_signed_push
- ) {
- configPromises.push(this.$.gpgEditor.loadData());
+ if (this.serverConfig?.receive?.enable_signed_push && this.gpgEditor) {
+ configPromises.push(this.gpgEditor.loadData());
}
configPromises.push(
getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
- this._docsBaseUrl = baseUrl;
+ this.docsBaseUrl = baseUrl;
})
);
@@ -280,34 +270,728 @@
if (message) {
fireAlert(this, message);
}
- this.$.emailEditor.loadData();
+ this.emailEditor.loadData();
})
);
} else {
- promises.push(this.$.emailEditor.loadData());
+ promises.push(this.emailEditor.loadData());
}
this._testOnly_loadingPromise = Promise.all(promises).then(() => {
- this._loading = false;
+ this.loading = false;
// Handle anchor tag for initial load
this.handleLocationChange();
});
}
+ static override styles = [
+ sharedStyles,
+ paperStyles,
+ fontStyles,
+ formStyles,
+ menuPageStyles,
+ pageNavStyles,
+ css`
+ :host {
+ color: var(--primary-text-color);
+ }
+ h2 {
+ font-family: var(--header-font-family);
+ font-size: var(--font-size-h2);
+ font-weight: var(--font-weight-h2);
+ line-height: var(--line-height-h2);
+ }
+ .newEmailInput {
+ width: 20em;
+ }
+ #email {
+ margin-bottom: var(--spacing-l);
+ }
+ .main section.darkToggle {
+ display: block;
+ }
+ .filters p,
+ .darkToggle p {
+ margin-bottom: var(--spacing-l);
+ }
+ .queryExample em {
+ color: violet;
+ }
+ .toggle {
+ align-items: center;
+ display: flex;
+ margin-bottom: var(--spacing-l);
+ margin-right: var(--spacing-l);
+ }
+ `,
+ ];
+
+ override render() {
+ const isLoading = this.loading || this.loading === undefined;
+ return html`<div class="loading" ?hidden=${!isLoading}>Loading...</div>
+ <div ?hidden=${isLoading}>
+ <gr-page-nav class="navStyles">
+ <ul>
+ <li><a href="#Profile">Profile</a></li>
+ <li><a href="#Preferences">Preferences</a></li>
+ <li><a href="#DiffPreferences">Diff Preferences</a></li>
+ <li><a href="#EditPreferences">Edit Preferences</a></li>
+ <li><a href="#Menu">Menu</a></li>
+ <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
+ <li><a href="#Notifications">Notifications</a></li>
+ <li><a href="#EmailAddresses">Email Addresses</a></li>
+ ${when(
+ this.showHttpAuth(),
+ () =>
+ html`<li><a href="#HTTPCredentials">HTTP Credentials</a></li>`
+ )}
+ ${when(
+ this.serverConfig?.sshd,
+ () => html`<li><a href="#SSHKeys"> SSH Keys </a></li>`
+ )}
+ ${when(
+ this.serverConfig?.receive?.enable_signed_push,
+ () => html`<li><a href="#GPGKeys"> GPG Keys </a></li>`
+ )}
+ <li><a href="#Groups">Groups</a></li>
+ <li><a href="#Identities">Identities</a></li>
+ ${when(
+ this.serverConfig?.auth.use_contributor_agreements,
+ () => html`<li><a href="#Agreements">Agreements</a></li>`
+ )}
+ <li><a href="#MailFilters">Mail Filters</a></li>
+ <gr-endpoint-decorator name="settings-menu-item">
+ </gr-endpoint-decorator>
+ </ul>
+ </gr-page-nav>
+ <div class="main gr-form-styles">
+ <h1 class="heading-1">User Settings</h1>
+ <h2 id="Theme">Theme</h2>
+ <section class="darkToggle">
+ <div class="toggle">
+ <paper-toggle-button
+ aria-labelledby="darkThemeToggleLabel"
+ ?checked=${this.isDark}
+ @change=${this.handleToggleDark}
+ @click=${this.onTapDarkToggle}
+ ></paper-toggle-button>
+ <div id="darkThemeToggleLabel">
+ Dark theme (the toggle reloads the page)
+ </div>
+ </div>
+ </section>
+ <h2
+ id="Profile"
+ class=${this.computeHeaderClass(this.accountInfoChanged)}
+ >
+ Profile
+ </h2>
+ <fieldset id="profile">
+ <gr-account-info
+ id="accountInfo"
+ ?hasUnsavedChanges=${this.accountInfoChanged}
+ @unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
+ this.accountInfoChanged = e.detail.value;
+ }}
+ ></gr-account-info>
+ <gr-button
+ @click=${() => {
+ this.accountInfo.save();
+ }}
+ ?disabled=${!this.accountInfoChanged}
+ >Save changes</gr-button
+ >
+ </fieldset>
+ <h2
+ id="Preferences"
+ class=${this.computeHeaderClass(this.prefsChanged)}
+ >
+ Preferences
+ </h2>
+ <fieldset id="preferences">
+ <section>
+ <label class="title" for="changesPerPageSelect"
+ >Changes per page</label
+ >
+ <span class="value">
+ <gr-select
+ .bindValue=${this.convertToString(
+ this.localPrefs.changes_per_page
+ )}
+ @change=${() => {
+ this.localPrefs.changes_per_page = Number(
+ this.changesPerPageSelect.value
+ ) as 10 | 25 | 50 | 100;
+ this.prefsChanged = true;
+ }}
+ >
+ <select id="changesPerPageSelect">
+ <option value="10">10 rows per page</option>
+ <option value="25">25 rows per page</option>
+ <option value="50">50 rows per page</option>
+ <option value="100">100 rows per page</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="dateTimeFormatSelect"
+ >Date/time format</label
+ >
+ <span class="value">
+ <gr-select
+ .bindValue=${this.convertToString(
+ this.localPrefs.date_format
+ )}
+ @change=${() => {
+ this.localPrefs.date_format = this.dateTimeFormatSelect
+ .value as DateFormat;
+ this.prefsChanged = true;
+ }}
+ >
+ <select id="dateTimeFormatSelect">
+ <option value="STD">Jun 3 ; Jun 3, 2016</option>
+ <option value="US">06/03 ; 06/03/16</option>
+ <option value="ISO">06-03 ; 2016-06-03</option>
+ <option value="EURO">3. Jun ; 03.06.2016</option>
+ <option value="UK">03/06 ; 03/06/2016</option>
+ </select>
+ </gr-select>
+ <gr-select
+ .bindValue=${this.convertToString(
+ this.localPrefs.time_format
+ )}
+ aria-label="Time Format"
+ @change=${() => {
+ this.localPrefs.time_format = this.timeFormatSelect
+ .value as TimeFormat;
+ this.prefsChanged = true;
+ }}
+ >
+ <select id="timeFormatSelect">
+ <option value="HHMM_12">4:10 PM</option>
+ <option value="HHMM_24">16:10</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="emailNotificationsSelect"
+ >Email notifications</label
+ >
+ <span class="value">
+ <gr-select
+ .bindValue=${this.convertToString(
+ this.localPrefs.email_strategy
+ )}
+ @change=${() => {
+ this.localPrefs.email_strategy = this
+ .emailNotificationsSelect.value as EmailStrategy;
+ this.prefsChanged = true;
+ }}
+ >
+ <select id="emailNotificationsSelect">
+ <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+ <option value="ENABLED">
+ Only comments left by others
+ </option>
+ <option value="ATTENTION_SET_ONLY">
+ Only when I am in the attention set
+ </option>
+ <option value="DISABLED">None</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section
+ ?hidden=${!this.convertToString(this.localPrefs.email_format)}
+ >
+ <label class="title" for="emailFormatSelect">Email format</label>
+ <span class="value">
+ <gr-select
+ .bindValue=${this.convertToString(
+ this.localPrefs.email_format
+ )}
+ @change=${() => {
+ this.localPrefs.email_format = this.emailFormatSelect
+ .value as EmailFormat;
+ this.prefsChanged = true;
+ }}
+ >
+ <select id="emailFormatSelect">
+ <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+ <option value="PLAINTEXT">Plaintext only</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section ?hidden=${!this.localPrefs.default_base_for_merges}>
+ <span class="title">Default Base For Merges</span>
+ <span class="value">
+ <gr-select
+ .bindValue=${this.convertToString(
+ this.localPrefs.default_base_for_merges
+ )}
+ @change=${() => {
+ this.localPrefs.default_base_for_merges = this
+ .defaultBaseForMergesSelect.value as DefaultBase;
+ this.prefsChanged = true;
+ }}
+ >
+ <select id="defaultBaseForMergesSelect">
+ <option value="AUTO_MERGE">Auto Merge</option>
+ <option value="FIRST_PARENT">First Parent</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="relativeDateInChangeTable"
+ >Show Relative Dates In Changes Table</label
+ >
+ <span class="value">
+ <input
+ id="relativeDateInChangeTable"
+ type="checkbox"
+ ?checked=${this.localPrefs.relative_date_in_change_table}
+ @change=${() => {
+ this.localPrefs.relative_date_in_change_table =
+ this.relativeDateInChangeTable.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <span class="title">Diff view</span>
+ <span class="value">
+ <gr-select
+ .bindValue=${this.convertToString(this.localPrefs.diff_view)}
+ @change=${() => {
+ this.localPrefs.diff_view = this.diffViewSelect
+ .value as DiffViewMode;
+ this.prefsChanged = true;
+ }}
+ >
+ <select id="diffViewSelect">
+ <option value="SIDE_BY_SIDE">Side by side</option>
+ <option value="UNIFIED_DIFF">Unified diff</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label for="showSizeBarsInFileList" class="title"
+ >Show size bars in file list</label
+ >
+ <span class="value">
+ <input
+ id="showSizeBarsInFileList"
+ type="checkbox"
+ ?checked=${this.localPrefs.size_bar_in_change_table}
+ @change=${() => {
+ this.localPrefs.size_bar_in_change_table =
+ this.showSizeBarsInFileList.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="publishCommentsOnPush" class="title"
+ >Publish comments on push</label
+ >
+ <span class="value">
+ <input
+ id="publishCommentsOnPush"
+ type="checkbox"
+ ?checked=${this.localPrefs.publish_comments_on_push}
+ @change=${() => {
+ this.localPrefs.publish_comments_on_push =
+ this.publishCommentsOnPush.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="workInProgressByDefault" class="title"
+ >Set new changes to "work in progress" by default</label
+ >
+ <span class="value">
+ <input
+ id="workInProgressByDefault"
+ type="checkbox"
+ ?checked=${this.localPrefs.work_in_progress_by_default}
+ @change=${() => {
+ this.localPrefs.work_in_progress_by_default =
+ this.workInProgressByDefault.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="disableKeyboardShortcuts" class="title"
+ >Disable all keyboard shortcuts</label
+ >
+ <span class="value">
+ <input
+ id="disableKeyboardShortcuts"
+ type="checkbox"
+ ?checked=${this.localPrefs.disable_keyboard_shortcuts}
+ @change=${() => {
+ this.localPrefs.disable_keyboard_shortcuts =
+ this.disableKeyboardShortcuts.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="disableTokenHighlighting" class="title"
+ >Disable token highlighting on hover</label
+ >
+ <span class="value">
+ <input
+ id="disableTokenHighlighting"
+ type="checkbox"
+ ?checked=${this.localPrefs.disable_token_highlighting}
+ @change=${() => {
+ this.localPrefs.disable_token_highlighting =
+ this.disableTokenHighlighting.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="insertSignedOff" class="title">
+ Insert Signed-off-by Footer For Inline Edit Changes
+ </label>
+ <span class="value">
+ <input
+ id="insertSignedOff"
+ type="checkbox"
+ ?checked=${this.localPrefs.signed_off_by}
+ @change=${() => {
+ this.localPrefs.signed_off_by =
+ this.insertSignedOff.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ <gr-button
+ id="savePrefs"
+ @click=${this.handleSavePreferences}
+ ?disabled=${!this.prefsChanged}
+ >Save changes</gr-button
+ >
+ </fieldset>
+ <h2
+ id="DiffPreferences"
+ class=${this.computeHeaderClass(this.diffPrefsChanged)}
+ >
+ Diff Preferences
+ </h2>
+ <fieldset id="diffPreferences">
+ <gr-diff-preferences
+ id="diffPrefs"
+ @has-unsaved-changes-changed=${(
+ e: ValueChangedEvent<boolean>
+ ) => {
+ this.diffPrefsChanged = e.detail.value;
+ }}
+ ></gr-diff-preferences>
+ <gr-button
+ id="saveDiffPrefs"
+ @click=${() => {
+ this.diffPrefs.save();
+ }}
+ ?disabled=${!this.diffPrefsChanged}
+ >Save changes</gr-button
+ >
+ </fieldset>
+ <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
+ <gr-menu-editor></gr-menu-editor>
+ <h2
+ id="ChangeTableColumns"
+ class=${this.computeHeaderClass(this.changeTableChanged)}
+ >
+ Change Table Columns
+ </h2>
+ <fieldset id="changeTableColumns">
+ <gr-change-table-editor
+ .showNumber=${this.showNumber}
+ @show-number-changed=${(e: ValueChangedEvent<boolean>) => {
+ this.showNumber = e.detail.value;
+ this.changeTableChanged = true;
+ }}
+ .serverConfig=${this.serverConfig}
+ .defaultColumns=${this.localChangeTableColumns}
+ @displayed-columns-changed=${(e: ValueChangedEvent<string[]>) => {
+ this.localChangeTableColumns = e.detail.value;
+ this.changeTableChanged = true;
+ }}
+ >
+ </gr-change-table-editor>
+ <gr-button
+ id="saveChangeTable"
+ @click=${this.handleSaveChangeTable}
+ ?disabled=${!this.changeTableChanged}
+ >Save changes</gr-button
+ >
+ </fieldset>
+ <h2
+ id="Notifications"
+ class=${this.computeHeaderClass(this.watchedProjectsChanged)}
+ >
+ Notifications
+ </h2>
+ <fieldset id="watchedProjects">
+ <gr-watched-projects-editor
+ ?hasUnsavedChanges=${this.watchedProjectsChanged}
+ @has-unsaved-changes-changed=${(
+ e: ValueChangedEvent<boolean>
+ ) => {
+ this.watchedProjectsChanged = e.detail.value;
+ }}
+ id="watchedProjectsEditor"
+ ></gr-watched-projects-editor>
+ <gr-button
+ @click=${() => {
+ this.watchedProjectsEditor.save();
+ }}
+ ?disabled=${!this.watchedProjectsChanged}
+ id="_handleSaveWatchedProjects"
+ >Save changes</gr-button
+ >
+ </fieldset>
+ <h2
+ id="EmailAddresses"
+ class=${this.computeHeaderClass(this.emailsChanged)}
+ >
+ Email Addresses
+ </h2>
+ <fieldset id="email">
+ <gr-email-editor
+ id="emailEditor"
+ ?hasUnsavedChanges=${this.emailsChanged}
+ @has-unsaved-changes-changed=${(
+ e: ValueChangedEvent<boolean>
+ ) => {
+ this.emailsChanged = e.detail.value;
+ }}
+ ></gr-email-editor>
+ <gr-button
+ @click=${() => {
+ this.emailEditor.save();
+ }}
+ ?disabled=${!this.emailsChanged}
+ >Save changes</gr-button
+ >
+ </fieldset>
+ <fieldset id="newEmail">
+ <section>
+ <span class="title">New email address</span>
+ <span class="value">
+ <iron-input
+ class="newEmailInput"
+ .bindValue=${this.newEmail}
+ @bind-value-changed=${(e: BindValueChangeEvent) => {
+ this.newEmail = e.detail.value;
+ }}
+ @keydown=${this.handleNewEmailKeydown}
+ >
+ <input
+ class="newEmailInput"
+ type="text"
+ ?disabled=${this.addingEmail}
+ @keydown=${this.handleNewEmailKeydown}
+ placeholder="email@example.com"
+ />
+ </iron-input>
+ </span>
+ </section>
+ <section
+ id="verificationSentMessage"
+ ?hidden=${!this.lastSentVerificationEmail}
+ >
+ <p>
+ A verification email was sent to
+ <em>${this.lastSentVerificationEmail}</em>. Please check your
+ inbox.
+ </p>
+ </section>
+ <gr-button
+ ?disabled=${!this.computeAddEmailButtonEnabled()}
+ @click=${this.handleAddEmailButton}
+ >Send verification</gr-button
+ >
+ </fieldset>
+ ${when(
+ this.showHttpAuth(),
+ () => html` <div>
+ <h2 id="HTTPCredentials">HTTP Credentials</h2>
+ <fieldset>
+ <gr-http-password id="httpPass"></gr-http-password>
+ </fieldset>
+ </div>`
+ )}
+ ${when(
+ this.serverConfig?.sshd,
+ () => html`<h2
+ id="SSHKeys"
+ class=${this.computeHeaderClass(this.keysChanged)}
+ >
+ SSH keys
+ </h2>
+ <gr-ssh-editor
+ id="sshEditor"
+ ?hasUnsavedChanges=${this.keysChanged}
+ @has-unsaved-changes-changed=${(
+ e: ValueChangedEvent<boolean>
+ ) => {
+ this.keysChanged = e.detail.value;
+ }}
+ ></gr-ssh-editor>`
+ )}
+ ${when(
+ this.serverConfig?.receive?.enable_signed_push,
+ () => html`<div>
+ <h2
+ id="GPGKeys"
+ class=${this.computeHeaderClass(this.gpgKeysChanged)}
+ >
+ GPG keys
+ </h2>
+ <gr-gpg-editor
+ id="gpgEditor"
+ ?hasUnsavedChanges=${this.gpgKeysChanged}
+ @has-unsaved-changes-changed=${(
+ e: ValueChangedEvent<boolean>
+ ) => {
+ this.gpgKeysChanged = e.detail.value;
+ }}
+ ></gr-gpg-editor>
+ </div>`
+ )}
+ <h2 id="Groups">Groups</h2>
+ <fieldset>
+ <gr-group-list id="groupList"></gr-group-list>
+ </fieldset>
+ <h2 id="Identities">Identities</h2>
+ <fieldset>
+ <gr-identities
+ id="identities"
+ .serverConfig=${this.serverConfig}
+ ></gr-identities>
+ </fieldset>
+ ${when(
+ this.serverConfig?.auth.use_contributor_agreements,
+ () => html`<h2 id="Agreements">Agreements</h2>
+ <fieldset>
+ <gr-agreements-list id="agreementsList"></gr-agreements-list>
+ </fieldset>`
+ )}
+ <h2 id="MailFilters">Mail Filters</h2>
+ <fieldset class="filters">
+ <p>
+ Gerrit emails include metadata about the change to support writing
+ mail filters.
+ </p>
+ <p>
+ Here are some example Gmail queries that can be used for filters
+ or for searching through archived messages. View the
+ <a
+ href=${this.getFilterDocsLink(this.docsBaseUrl)}
+ target="_blank"
+ rel="nofollow"
+ >Gerrit documentation</a
+ >
+ for the complete set of footers.
+ </p>
+ <table>
+ <tbody>
+ <tr>
+ <th>Name</th>
+ <th>Query</th>
+ </tr>
+ <tr>
+ <td>Changes requesting my review</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Reviewer: <em>Your Name</em>
+ <<em>your.email@example.com</em>>"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes requesting my attention</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Attention: <em>Your Name</em>
+ <<em>your.email@example.com</em>>"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes from a specific owner</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Owner: <em>Owner name</em>
+ <<em>owner.email@example.com</em>>"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes targeting a specific branch</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Branch: <em>branch-name</em>"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes in a specific project</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Project: <em>project-name</em>"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Messages related to a specific Change ID</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Change-Id: <em>Change ID</em>"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Messages related to a specific change number</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Change-Number: <em>change number</em>"
+ </code>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </fieldset>
+ <gr-endpoint-decorator name="settings-screen">
+ </gr-endpoint-decorator>
+ </div>
+ </div>`;
+ }
+
override disconnectedCallback() {
window.removeEventListener('location-change', this.handleLocationChange);
super.disconnectedCallback();
}
- handleUnsavedChangesChanged(e: ValueChangedEvent) {
- this._keysChanged = !!e.detail.value;
- }
-
- _handleGpgEditorHasSavedChanges(e: ValueChangedEvent<boolean>) {
- this._gpgKeysChanged = e.detail.value;
- }
-
private readonly handleLocationChange = () => {
// Handle anchor tag after dom attached
const urlHash = window.location.hash;
@@ -321,180 +1005,82 @@
};
reloadAccountDetail() {
- Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+ Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
}
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _copyPrefs(direction: CopyPrefsDirection) {
- let to;
- let from;
+ private copyPrefs(direction: CopyPrefsDirection) {
if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
- from = this._localPrefs;
- to = 'prefs';
+ this.prefs = {
+ ...this.localPrefs,
+ };
} else {
- from = this.prefs;
- to = '_localPrefs';
- }
- for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
- this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+ this.localPrefs = {
+ ...this.prefs,
+ };
}
}
- @observe('_localChangeTableColumns', '_showNumber')
- _handleChangeTableChanged() {
- if (this._isLoading()) {
- return;
- }
- this._changeTableChanged = true;
- }
-
- @observe('_localPrefs.*')
- _handlePrefsChanged() {
- if (this._isLoading()) {
- return;
- }
- this._prefsChanged = true;
- }
-
- _handleRelativeDateInChangeTable() {
- this.set(
- '_localPrefs.relative_date_in_change_table',
- this.$.relativeDateInChangeTable.checked
- );
- }
-
- _handleShowSizeBarsInFileListChanged() {
- this.set(
- '_localPrefs.size_bar_in_change_table',
- this.$.showSizeBarsInFileList.checked
- );
- }
-
- _handlePublishCommentsOnPushChanged() {
- this.set(
- '_localPrefs.publish_comments_on_push',
- this.$.publishCommentsOnPush.checked
- );
- }
-
- _handleDisableKeyboardShortcutsChanged() {
- this.set(
- '_localPrefs.disable_keyboard_shortcuts',
- this.$.disableKeyboardShortcuts.checked
- );
- }
-
- _handleDisableTokenHighlightingChanged() {
- this.set(
- '_localPrefs.disable_token_highlighting',
- this.$.disableTokenHighlighting.checked
- );
- }
-
- _handleWorkInProgressByDefault() {
- this.set(
- '_localPrefs.work_in_progress_by_default',
- this.$.workInProgressByDefault.checked
- );
- }
-
- _handleInsertSignedOff() {
- this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
- }
-
- _handleSaveAccountInfo() {
- this.$.accountInfo.save();
- }
-
- _handleSavePreferences() {
- this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+ // private but used in test
+ handleSavePreferences() {
+ this.copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
return this.restApiService.savePreferences(this.prefs).then(() => {
- this._prefsChanged = false;
+ this.prefsChanged = false;
});
}
- _handleSaveChangeTable() {
- this.set('prefs.change_table', this._localChangeTableColumns);
- this.set('prefs.legacycid_in_change_table', this._showNumber);
+ // private but used in test
+ handleSaveChangeTable() {
+ this.prefs.change_table = this.localChangeTableColumns;
+ this.prefs.legacycid_in_change_table = this.showNumber;
return this.restApiService.savePreferences(this.prefs).then(() => {
- this._changeTableChanged = false;
+ this.changeTableChanged = false;
});
}
- _handleSaveDiffPreferences() {
- this.$.diffPrefs.save();
- }
-
- _handleSaveWatchedProjects() {
- this.$.watchedProjectsEditor.save();
- }
-
- _computeHeaderClass(changed?: boolean) {
+ private computeHeaderClass(changed?: boolean) {
return changed ? 'edited' : '';
}
- _handleSaveEmails() {
- this.$.emailEditor.save();
- }
-
- _handleNewEmailKeydown(e: KeyboardEvent) {
+ // private but used in test
+ handleNewEmailKeydown(e: KeyboardEvent) {
if (e.keyCode === 13) {
// Enter
e.stopPropagation();
- this._handleAddEmailButton();
+ this.handleAddEmailButton();
}
}
- _isNewEmailValid(newEmail?: string): newEmail is string {
+ // private but used in test
+ isNewEmailValid(newEmail?: string): newEmail is string {
return !!newEmail && newEmail.includes('@');
}
- _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
- return this._isNewEmailValid(newEmail) && !addingEmail;
+ // private but used in test
+ computeAddEmailButtonEnabled() {
+ return this.isNewEmailValid(this.newEmail) && !this.addingEmail;
}
- _handleAddEmailButton() {
- if (!this._isNewEmailValid(this._newEmail)) return;
+ // private but used in test
+ handleAddEmailButton() {
+ if (!this.isNewEmailValid(this.newEmail)) return;
- this._addingEmail = true;
- this.restApiService.addAccountEmail(this._newEmail).then(response => {
- this._addingEmail = false;
+ this.addingEmail = true;
+ this.restApiService.addAccountEmail(this.newEmail).then(response => {
+ this.addingEmail = false;
// If it was unsuccessful.
if (response.status < 200 || response.status >= 300) {
return;
}
- this._lastSentVerificationEmail = this._newEmail;
- this._newEmail = '';
+ this.lastSentVerificationEmail = this.newEmail;
+ this.newEmail = '';
});
}
- _handleAccountInfoUnsavedChanges(e: ValueChangedEvent<boolean>) {
- this._accountInfoChanged = e.detail.value;
- }
-
- _handleShowNumberChanged(e: ValueChangedEvent<boolean>) {
- this._showNumber = e.detail.value;
- }
-
- _handleDisplayedColumnsChanged(e: ValueChangedEvent<string[]>) {
- this._localChangeTableColumns = e.detail.value;
- }
-
- _handleHasEmailsChanged(e: ValueChangedEvent<boolean>) {
- this._emailsChanged = e.detail.value;
- }
-
- _handleHasProjectsChanged(e: ValueChangedEvent<boolean>) {
- this._watchedProjectsChanged = e.detail.value;
- }
-
- _getFilterDocsLink(docsBaseUrl?: string | null) {
+ // private but used in test
+ getFilterDocsLink(docsBaseUrl?: string | null) {
let base = docsBaseUrl;
if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
base = GERRIT_DOCS_BASE_URL;
@@ -506,8 +1092,8 @@
return base + GERRIT_DOCS_FILTER_PATH;
}
- _handleToggleDark() {
- if (this._isDark) {
+ private handleToggleDark() {
+ if (this.isDark) {
window.localStorage.removeItem('dark-theme');
} else {
window.localStorage.setItem('dark-theme', 'true');
@@ -515,14 +1101,16 @@
this.reloadPage();
}
+ // private but used in test
reloadPage() {
windowLocationReload();
}
- _showHttpAuth(config?: ServerInfo) {
- if (config && config.auth && config.auth.git_basic_auth_policy) {
+ // private but used in test
+ showHttpAuth() {
+ if (this.serverConfig?.auth?.git_basic_auth_policy) {
return HTTP_AUTH.includes(
- config.auth.git_basic_auth_policy.toUpperCase()
+ this.serverConfig.auth.git_basic_auth_policy.toUpperCase()
);
}
@@ -532,57 +1120,17 @@
/**
* Work around a issue on iOS when clicking turns into double tap
*/
- _onTapDarkToggle(e: Event) {
+ private onTapDarkToggle(e: Event) {
e.preventDefault();
}
- _handleChangesPerPage() {
- this.set(
- '_localPrefs.changes_per_page',
- Number(this.$.changesPerPageSelect.value)
- );
- }
-
- _handleDateFormat() {
- this.set('_localPrefs.date_format', this.$.dateTimeFormatSelect.value);
- }
-
- _handleTimeFormat() {
- this.set('_localPrefs.time_format', this.$.timeFormatSelect.value);
- }
-
- _handleEmailStrategy() {
- this.set(
- '_localPrefs.email_strategy',
- this.$.emailNotificationsSelect.value
- );
- }
-
- _handleEmailFormat() {
- this.set('_localPrefs.email_format', this.$.emailFormatSelect.value);
- }
-
- _handleDefaultBaseForMerges() {
- this.set(
- '_localPrefs.default_base_for_merges',
- this.$.defaultBaseForMergesSelect.value
- );
- }
-
- _handleDiffView() {
- this.set(
- '_localPrefs.diff_view',
- this.$.diffViewSelect.value as DiffViewMode
- );
- }
-
/**
* bind-value has type string so we have to convert anything inputed
* to string.
*
* This is so typescript template checker doesn't fail.
*/
- _convertToString(
+ private convertToString(
key?:
| DateFormat
| DefaultBase
@@ -594,10 +1142,6 @@
) {
return key !== undefined ? String(key) : '';
}
-
- _handleHasUnsavedChangesChanged(e: ValueChangedEvent<boolean>) {
- this._diffPrefsChanged = e.detail.value;
- }
}
declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
deleted file mode 100644
index 9c2e929..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ /dev/null
@@ -1,591 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="gr-font-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <style include="gr-paper-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <style include="shared-styles">
- :host {
- color: var(--primary-text-color);
- }
- h2 {
- font-family: var(--header-font-family);
- font-size: var(--font-size-h2);
- font-weight: var(--font-weight-h2);
- line-height: var(--line-height-h2);
- }
- .newEmailInput {
- width: 20em;
- }
- #email {
- margin-bottom: var(--spacing-l);
- }
- .main section.darkToggle {
- display: block;
- }
- .filters p,
- .darkToggle p {
- margin-bottom: var(--spacing-l);
- }
- .queryExample em {
- color: violet;
- }
- .toggle {
- align-items: center;
- display: flex;
- margin-bottom: var(--spacing-l);
- margin-right: var(--spacing-l);
- }
- </style>
- <style include="gr-form-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <style include="gr-menu-page-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <style include="gr-page-nav-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <div class="loading" hidden$="[[!_loading]]">Loading...</div>
- <div hidden$="[[_loading]]" hidden="">
- <gr-page-nav class="navStyles">
- <ul>
- <li><a href="#Profile">Profile</a></li>
- <li><a href="#Preferences">Preferences</a></li>
- <li><a href="#DiffPreferences">Diff Preferences</a></li>
- <li><a href="#EditPreferences">Edit Preferences</a></li>
- <li><a href="#Menu">Menu</a></li>
- <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
- <li><a href="#Notifications">Notifications</a></li>
- <li><a href="#EmailAddresses">Email Addresses</a></li>
- <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
- <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
- </template>
- <li hidden$="[[!_serverConfig.sshd]]">
- <a href="#SSHKeys"> SSH Keys </a>
- </li>
- <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
- <a href="#GPGKeys"> GPG Keys </a>
- </li>
- <li><a href="#Groups">Groups</a></li>
- <li><a href="#Identities">Identities</a></li>
- <template
- is="dom-if"
- if="[[_serverConfig.auth.use_contributor_agreements]]"
- >
- <li>
- <a href="#Agreements">Agreements</a>
- </li>
- </template>
- <li><a href="#MailFilters">Mail Filters</a></li>
- <gr-endpoint-decorator name="settings-menu-item">
- </gr-endpoint-decorator>
- </ul>
- </gr-page-nav>
- <div class="main gr-form-styles">
- <h1 class="heading-1">User Settings</h1>
- <h2 id="Theme">Theme</h2>
- <section class="darkToggle">
- <div class="toggle">
- <paper-toggle-button
- aria-labelledby="darkThemeToggleLabel"
- checked="[[_isDark]]"
- on-change="_handleToggleDark"
- on-click="_onTapDarkToggle"
- ></paper-toggle-button>
- <div id="darkThemeToggleLabel">
- Dark theme (the toggle reloads the page)
- </div>
- </div>
- </section>
- <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
- Profile
- </h2>
- <fieldset id="profile">
- <gr-account-info
- id="accountInfo"
- has-unsaved-changes="[[_accountInfoChanged]]"
- on-unsaved-changes-changed="_handleAccountInfoUnsavedChanges"
- ></gr-account-info>
- <gr-button
- on-click="_handleSaveAccountInfo"
- disabled="[[!_accountInfoChanged]]"
- >Save changes</gr-button
- >
- </fieldset>
- <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
- Preferences
- </h2>
- <fieldset id="preferences">
- <section>
- <label class="title" for="changesPerPageSelect"
- >Changes per page</label
- >
- <span class="value">
- <gr-select
- bind-value="[[_convertToString(_localPrefs.changes_per_page)]]"
- on-change="_handleChangesPerPage"
- >
- <select id="changesPerPageSelect">
- <option value="10">10 rows per page</option>
- <option value="25">25 rows per page</option>
- <option value="50">50 rows per page</option>
- <option value="100">100 rows per page</option>
- </select>
- </gr-select>
- </span>
- </section>
- <section>
- <label class="title" for="dateTimeFormatSelect"
- >Date/time format</label
- >
- <span class="value">
- <gr-select
- bind-value="[[_convertToString(_localPrefs.date_format)]]"
- on-change="_handleDateFormat"
- >
- <select id="dateTimeFormatSelect">
- <option value="STD">Jun 3 ; Jun 3, 2016</option>
- <option value="US">06/03 ; 06/03/16</option>
- <option value="ISO">06-03 ; 2016-06-03</option>
- <option value="EURO">3. Jun ; 03.06.2016</option>
- <option value="UK">03/06 ; 03/06/2016</option>
- </select>
- </gr-select>
- <gr-select
- bind-value="[[_convertToString(_localPrefs.time_format)]]"
- aria-label="Time Format"
- on-change="_handleTimeFormat"
- >
- <select id="timeFormatSelect">
- <option value="HHMM_12">4:10 PM</option>
- <option value="HHMM_24">16:10</option>
- </select>
- </gr-select>
- </span>
- </section>
- <section>
- <label class="title" for="emailNotificationsSelect"
- >Email notifications</label
- >
- <span class="value">
- <gr-select
- bind-value="[[_convertToString(_localPrefs.email_strategy)]]"
- on-change="_handleEmailStrategy"
- >
- <select id="emailNotificationsSelect">
- <option value="CC_ON_OWN_COMMENTS">Every comment</option>
- <option value="ENABLED">Only comments left by others</option>
- <option value="ATTENTION_SET_ONLY">
- Only when I am in the attention set
- </option>
- <option value="DISABLED">None</option>
- </select>
- </gr-select>
- </span>
- </section>
- <section hidden$="[[!_convertToString(_localPrefs.email_format)]]">
- <label class="title" for="emailFormatSelect">Email format</label>
- <span class="value">
- <gr-select
- bind-value="[[_convertToString(_localPrefs.email_format)]]"
- on-change="_handleEmailFormat"
- >
- <select id="emailFormatSelect">
- <option value="HTML_PLAINTEXT">HTML and plaintext</option>
- <option value="PLAINTEXT">Plaintext only</option>
- </select>
- </gr-select>
- </span>
- </section>
- <section hidden$="[[!_localPrefs.default_base_for_merges]]">
- <span class="title">Default Base For Merges</span>
- <span class="value">
- <gr-select
- bind-value="[[_convertToString(_localPrefs.default_base_for_merges)]]"
- on-change="_handleDefaultBaseForMerges"
- >
- <select id="defaultBaseForMergesSelect">
- <option value="AUTO_MERGE">Auto Merge</option>
- <option value="FIRST_PARENT">First Parent</option>
- </select>
- </gr-select>
- </span>
- </section>
- <section>
- <label class="title" for="relativeDateInChangeTable"
- >Show Relative Dates In Changes Table</label
- >
- <span class="value">
- <input
- id="relativeDateInChangeTable"
- type="checkbox"
- checked$="[[_localPrefs.relative_date_in_change_table]]"
- on-change="_handleRelativeDateInChangeTable"
- />
- </span>
- </section>
- <section>
- <span class="title">Diff view</span>
- <span class="value">
- <gr-select
- bind-value="[[_convertToString(_localPrefs.diff_view)]]"
- on-change="_handleDiffView"
- >
- <select id="diffViewSelect">
- <option value="SIDE_BY_SIDE">Side by side</option>
- <option value="UNIFIED_DIFF">Unified diff</option>
- </select>
- </gr-select>
- </span>
- </section>
- <section>
- <label for="showSizeBarsInFileList" class="title"
- >Show size bars in file list</label
- >
- <span class="value">
- <input
- id="showSizeBarsInFileList"
- type="checkbox"
- checked$="[[_localPrefs.size_bar_in_change_table]]"
- on-change="_handleShowSizeBarsInFileListChanged"
- />
- </span>
- </section>
- <section>
- <label for="publishCommentsOnPush" class="title"
- >Publish comments on push</label
- >
- <span class="value">
- <input
- id="publishCommentsOnPush"
- type="checkbox"
- checked$="[[_localPrefs.publish_comments_on_push]]"
- on-change="_handlePublishCommentsOnPushChanged"
- />
- </span>
- </section>
- <section>
- <label for="workInProgressByDefault" class="title"
- >Set new changes to "work in progress" by default</label
- >
- <span class="value">
- <input
- id="workInProgressByDefault"
- type="checkbox"
- checked$="[[_localPrefs.work_in_progress_by_default]]"
- on-change="_handleWorkInProgressByDefault"
- />
- </span>
- </section>
- <section>
- <label for="disableKeyboardShortcuts" class="title"
- >Disable all keyboard shortcuts</label
- >
- <span class="value">
- <input
- id="disableKeyboardShortcuts"
- type="checkbox"
- checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
- on-change="_handleDisableKeyboardShortcutsChanged"
- />
- </span>
- </section>
- <section>
- <label for="disableTokenHighlighting" class="title"
- >Disable token highlighting on hover</label
- >
- <span class="value">
- <input
- id="disableTokenHighlighting"
- type="checkbox"
- checked$="[[_localPrefs.disable_token_highlighting]]"
- on-change="_handleDisableTokenHighlightingChanged"
- />
- </span>
- </section>
- <section>
- <label for="insertSignedOff" class="title">
- Insert Signed-off-by Footer For Inline Edit Changes
- </label>
- <span class="value">
- <input
- id="insertSignedOff"
- type="checkbox"
- checked$="[[_localPrefs.signed_off_by]]"
- on-change="_handleInsertSignedOff"
- />
- </span>
- </section>
- <gr-button
- id="savePrefs"
- on-click="_handleSavePreferences"
- disabled="[[!_prefsChanged]]"
- >Save changes</gr-button
- >
- </fieldset>
- <h2
- id="DiffPreferences"
- class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
- >
- Diff Preferences
- </h2>
- <fieldset id="diffPreferences">
- <gr-diff-preferences
- id="diffPrefs"
- on-has-unsaved-changes-changed="_handleHasUnsavedChangesChanged"
- ></gr-diff-preferences>
- <gr-button
- id="saveDiffPrefs"
- on-click="_handleSaveDiffPreferences"
- disabled$="[[!_diffPrefsChanged]]"
- >Save changes</gr-button
- >
- </fieldset>
- <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
- <gr-menu-editor></gr-menu-editor>
- <h2
- id="ChangeTableColumns"
- class$="[[_computeHeaderClass(_changeTableChanged)]]"
- >
- Change Table Columns
- </h2>
- <fieldset id="changeTableColumns">
- <gr-change-table-editor
- show-number="[[_showNumber]]"
- on-show-number-changed="_handleShowNumberChanged"
- server-config="[[_serverConfig]]"
- displayed-columns="[[_localChangeTableColumns]]"
- on-displayed-columns-changed="_handleDisplayedColumnsChanged"
- >
- </gr-change-table-editor>
- <gr-button
- id="saveChangeTable"
- on-click="_handleSaveChangeTable"
- disabled="[[!_changeTableChanged]]"
- >Save changes</gr-button
- >
- </fieldset>
- <h2
- id="Notifications"
- class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
- >
- Notifications
- </h2>
- <fieldset id="watchedProjects">
- <gr-watched-projects-editor
- has-unsaved-changes="[[_watchedProjectsChanged]]"
- on-has-unsaved-changes-changed="_handleHasProjectsChanged"
- id="watchedProjectsEditor"
- ></gr-watched-projects-editor>
- <gr-button
- on-click="_handleSaveWatchedProjects"
- disabled$="[[!_watchedProjectsChanged]]"
- id="_handleSaveWatchedProjects"
- >Save changes</gr-button
- >
- </fieldset>
- <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
- Email Addresses
- </h2>
- <fieldset id="email">
- <gr-email-editor
- id="emailEditor"
- has-unsaved-changes="[[_emailsChanged]]"
- on-has-unsaved-changes-changed="_handleHasEmailsChanged"
- ></gr-email-editor>
- <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
- >Save changes</gr-button
- >
- </fieldset>
- <fieldset id="newEmail">
- <section>
- <span class="title">New email address</span>
- <span class="value">
- <iron-input
- class="newEmailInput"
- bind-value="{{_newEmail}}"
- type="text"
- on-keydown="_handleNewEmailKeydown"
- placeholder="email@example.com"
- >
- <input
- class="newEmailInput"
- type="text"
- disabled="[[_addingEmail]]"
- on-keydown="_handleNewEmailKeydown"
- placeholder="email@example.com"
- />
- </iron-input>
- </span>
- </section>
- <section
- id="verificationSentMessage"
- hidden$="[[!_lastSentVerificationEmail]]"
- >
- <p>
- A verification email was sent to
- <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
- </p>
- </section>
- <gr-button
- disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
- on-click="_handleAddEmailButton"
- >Send verification</gr-button
- >
- </fieldset>
- <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
- <div>
- <h2 id="HTTPCredentials">HTTP Credentials</h2>
- <fieldset>
- <gr-http-password id="httpPass"></gr-http-password>
- </fieldset>
- </div>
- </template>
- <div hidden$="[[!_serverConfig.sshd]]">
- <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
- SSH keys
- </h2>
- <gr-ssh-editor
- id="sshEditor"
- has-unsaved-changes-changed="handleUnsavedChangesChanged"
- ></gr-ssh-editor>
- </div>
- <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
- <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
- GPG keys
- </h2>
- <gr-gpg-editor
- id="gpgEditor"
- has-unsaved-changes="[[_gpgKeysChanged]]"
- on-has-unsaved-changes-changed="_handleGpgEditorHasSavedChanges"
- ></gr-gpg-editor>
- </div>
- <h2 id="Groups">Groups</h2>
- <fieldset>
- <gr-group-list id="groupList"></gr-group-list>
- </fieldset>
- <h2 id="Identities">Identities</h2>
- <fieldset>
- <gr-identities
- id="identities"
- server-config="[[_serverConfig]]"
- ></gr-identities>
- </fieldset>
- <template
- is="dom-if"
- if="[[_serverConfig.auth.use_contributor_agreements]]"
- >
- <h2 id="Agreements">Agreements</h2>
- <fieldset>
- <gr-agreements-list id="agreementsList"></gr-agreements-list>
- </fieldset>
- </template>
- <h2 id="MailFilters">Mail Filters</h2>
- <fieldset class="filters">
- <p>
- Gerrit emails include metadata about the change to support writing
- mail filters.
- </p>
- <p>
- Here are some example Gmail queries that can be used for filters or
- for searching through archived messages. View the
- <a
- href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
- target="_blank"
- rel="nofollow"
- >Gerrit documentation</a
- >
- for the complete set of footers.
- </p>
- <table>
- <tbody>
- <tr>
- <th>Name</th>
- <th>Query</th>
- </tr>
- <tr>
- <td>Changes requesting my review</td>
- <td>
- <code class="queryExample">
- "Gerrit-Reviewer: <em>Your Name</em>
- <<em>your.email@example.com</em>>"
- </code>
- </td>
- </tr>
- <tr>
- <td>Changes requesting my attention</td>
- <td>
- <code class="queryExample">
- "Gerrit-Attention: <em>Your Name</em>
- <<em>your.email@example.com</em>>"
- </code>
- </td>
- </tr>
- <tr>
- <td>Changes from a specific owner</td>
- <td>
- <code class="queryExample">
- "Gerrit-Owner: <em>Owner name</em>
- <<em>owner.email@example.com</em>>"
- </code>
- </td>
- </tr>
- <tr>
- <td>Changes targeting a specific branch</td>
- <td>
- <code class="queryExample">
- "Gerrit-Branch: <em>branch-name</em>"
- </code>
- </td>
- </tr>
- <tr>
- <td>Changes in a specific project</td>
- <td>
- <code class="queryExample">
- "Gerrit-Project: <em>project-name</em>"
- </code>
- </td>
- </tr>
- <tr>
- <td>Messages related to a specific Change ID</td>
- <td>
- <code class="queryExample">
- "Gerrit-Change-Id: <em>Change ID</em>"
- </code>
- </td>
- </tr>
- <tr>
- <td>Messages related to a specific change number</td>
- <td>
- <code class="queryExample">
- "Gerrit-Change-Number: <em>change number</em>"
- </code>
- </td>
- </tr>
- </tbody>
- </table>
- </fieldset>
- <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
- </div>
- </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 7f81f42..a514f00 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -19,7 +19,7 @@
import './gr-settings-view';
import {GrSettingsView} from './gr-settings-view';
import {GerritView} from '../../../services/router/router-model';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
import {
AuthInfo,
AccountDetailInfo,
@@ -56,7 +56,7 @@
let config: ServerInfo;
function valueOf(title: string, id: string) {
- const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+ const sections = queryAll(element, `#${id} section`);
let titleEl;
for (let i = 0; i < sections.length; i++) {
titleEl = sections[i].querySelector('.title');
@@ -122,10 +122,420 @@
stubRestApi('getAccountEmails').returns(Promise.resolve(undefined));
stubRestApi('getConfig').returns(Promise.resolve(config));
element = basicFixture.instantiate();
+ await element.updateComplete;
// Allow the element to render.
if (element._testOnly_loadingPromise)
await element._testOnly_loadingPromise;
+ await element.updateComplete;
+ });
+
+ test('renders', async () => {
+ sinon
+ .stub(element, 'getFilterDocsLink')
+ .returns('https://test.com/user-notify.html');
+ element.docsBaseUrl = 'https://test.com';
+ await element.updateComplete;
+ // this cannot be formatted with /* HTML */, because it breaks test
+ expect(element).shadowDom.to.equal(/* HTML*/ `<div
+ class="loading"
+ hidden=""
+ >
+ Loading...
+ </div>
+ <div>
+ <gr-page-nav class="navStyles">
+ <ul>
+ <li><a href="#Profile"> Profile </a></li>
+ <li><a href="#Preferences"> Preferences </a></li>
+ <li><a href="#DiffPreferences"> Diff Preferences </a></li>
+ <li><a href="#EditPreferences"> Edit Preferences </a></li>
+ <li><a href="#Menu"> Menu </a></li>
+ <li><a href="#ChangeTableColumns"> Change Table Columns </a></li>
+ <li><a href="#Notifications"> Notifications </a></li>
+ <li><a href="#EmailAddresses"> Email Addresses </a></li>
+ <li><a href="#Groups"> Groups </a></li>
+ <li><a href="#Identities"> Identities </a></li>
+ <li><a href="#MailFilters"> Mail Filters </a></li>
+ <gr-endpoint-decorator name="settings-menu-item">
+ </gr-endpoint-decorator>
+ </ul>
+ </gr-page-nav>
+ <div class="gr-form-styles main">
+ <h1 class="heading-1">User Settings</h1>
+ <h2 id="Theme">Theme</h2>
+ <section class="darkToggle">
+ <div class="toggle">
+ <paper-toggle-button
+ aria-disabled="false"
+ aria-labelledby="darkThemeToggleLabel"
+ aria-pressed="false"
+ role="button"
+ style="touch-action: none;"
+ tabindex="0"
+ toggles=""
+ >
+ </paper-toggle-button>
+ <div id="darkThemeToggleLabel">
+ Dark theme (the toggle reloads the page)
+ </div>
+ </div>
+ </section>
+ <h2 id="Profile">Profile</h2>
+ <fieldset id="profile">
+ <gr-account-info id="accountInfo"> </gr-account-info>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >
+ Save changes
+ </gr-button>
+ </fieldset>
+ <h2 id="Preferences">Preferences</h2>
+ <fieldset id="preferences">
+ <section>
+ <label class="title" for="changesPerPageSelect">
+ Changes per page
+ </label>
+ <span class="value">
+ <gr-select>
+ <select id="changesPerPageSelect">
+ <option value="10">10 rows per page</option>
+ <option value="25">25 rows per page</option>
+ <option value="50">50 rows per page</option>
+ <option value="100">100 rows per page</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="dateTimeFormatSelect">
+ Date/time format
+ </label>
+ <span class="value">
+ <gr-select>
+ <select id="dateTimeFormatSelect">
+ <option value="STD">Jun 3 ; Jun 3, 2016</option>
+ <option value="US">06/03 ; 06/03/16</option>
+ <option value="ISO">06-03 ; 2016-06-03</option>
+ <option value="EURO">3. Jun ; 03.06.2016</option>
+ <option value="UK">03/06 ; 03/06/2016</option>
+ </select>
+ </gr-select>
+ <gr-select aria-label="Time Format">
+ <select id="timeFormatSelect">
+ <option value="HHMM_12">4:10 PM</option>
+ <option value="HHMM_24">16:10</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="emailNotificationsSelect">
+ Email notifications
+ </label>
+ <span class="value">
+ <gr-select>
+ <select id="emailNotificationsSelect">
+ <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+ <option value="ENABLED">
+ Only comments left by others
+ </option>
+ <option value="ATTENTION_SET_ONLY">
+ Only when I am in the attention set
+ </option>
+ <option value="DISABLED">None</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="emailFormatSelect">
+ Email format
+ </label>
+ <span class="value">
+ <gr-select>
+ <select id="emailFormatSelect">
+ <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+ <option value="PLAINTEXT">Plaintext only</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <span class="title"> Default Base For Merges </span>
+ <span class="value">
+ <gr-select>
+ <select id="defaultBaseForMergesSelect">
+ <option value="AUTO_MERGE">Auto Merge</option>
+ <option value="FIRST_PARENT">First Parent</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="relativeDateInChangeTable">
+ Show Relative Dates In Changes Table
+ </label>
+ <span class="value">
+ <input id="relativeDateInChangeTable" type="checkbox" />
+ </span>
+ </section>
+ <section>
+ <span class="title"> Diff view </span>
+ <span class="value">
+ <gr-select>
+ <select id="diffViewSelect">
+ <option value="SIDE_BY_SIDE">Side by side</option>
+ <option value="UNIFIED_DIFF">Unified diff</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="showSizeBarsInFileList">
+ Show size bars in file list
+ </label>
+ <span class="value">
+ <input
+ checked=""
+ id="showSizeBarsInFileList"
+ type="checkbox"
+ />
+ </span>
+ </section>
+ <section>
+ <label class="title" for="publishCommentsOnPush">
+ Publish comments on push
+ </label>
+ <span class="value">
+ <input id="publishCommentsOnPush" type="checkbox" />
+ </span>
+ </section>
+ <section>
+ <label class="title" for="workInProgressByDefault">
+ Set new changes to "work in progress" by default
+ </label>
+ <span class="value">
+ <input id="workInProgressByDefault" type="checkbox" />
+ </span>
+ </section>
+ <section>
+ <label class="title" for="disableKeyboardShortcuts">
+ Disable all keyboard shortcuts
+ </label>
+ <span class="value">
+ <input id="disableKeyboardShortcuts" type="checkbox" />
+ </span>
+ </section>
+ <section>
+ <label class="title" for="disableTokenHighlighting">
+ Disable token highlighting on hover
+ </label>
+ <span class="value">
+ <input id="disableTokenHighlighting" type="checkbox" />
+ </span>
+ </section>
+ <section>
+ <label class="title" for="insertSignedOff">
+ Insert Signed-off-by Footer For Inline Edit Changes
+ </label>
+ <span class="value">
+ <input id="insertSignedOff" type="checkbox" />
+ </span>
+ </section>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ id="savePrefs"
+ role="button"
+ tabindex="-1"
+ >
+ Save changes
+ </gr-button>
+ </fieldset>
+ <h2 id="DiffPreferences">Diff Preferences</h2>
+ <fieldset id="diffPreferences">
+ <gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ id="saveDiffPrefs"
+ role="button"
+ tabindex="-1"
+ >
+ Save changes
+ </gr-button>
+ </fieldset>
+ <gr-edit-preferences id="editPrefs"> </gr-edit-preferences>
+ <gr-menu-editor> </gr-menu-editor>
+ <h2 id="ChangeTableColumns">Change Table Columns</h2>
+ <fieldset id="changeTableColumns">
+ <gr-change-table-editor> </gr-change-table-editor>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ id="saveChangeTable"
+ role="button"
+ tabindex="-1"
+ >
+ Save changes
+ </gr-button>
+ </fieldset>
+ <h2 id="Notifications">Notifications</h2>
+ <fieldset id="watchedProjects">
+ <gr-watched-projects-editor id="watchedProjectsEditor">
+ </gr-watched-projects-editor>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ id="_handleSaveWatchedProjects"
+ role="button"
+ tabindex="-1"
+ >
+ Save changes
+ </gr-button>
+ </fieldset>
+ <h2 id="EmailAddresses">Email Addresses</h2>
+ <fieldset id="email">
+ <gr-email-editor id="emailEditor"> </gr-email-editor>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >
+ Save changes
+ </gr-button>
+ </fieldset>
+ <fieldset id="newEmail">
+ <section>
+ <span class="title"> New email address </span>
+ <span class="value">
+ <iron-input class="newEmailInput">
+ <input
+ class="newEmailInput"
+ placeholder="email@example.com"
+ type="text"
+ />
+ </iron-input>
+ </span>
+ </section>
+ <section hidden="" id="verificationSentMessage">
+ <p>
+ A verification email was sent to <em>
+ </em>
+ . Please check your
+ inbox.
+ </p>
+ </section>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >
+ Send verification
+ </gr-button>
+ </fieldset>
+ <h2 id="Groups">Groups</h2>
+ <fieldset><gr-group-list id="groupList"> </gr-group-list></fieldset>
+ <h2 id="Identities">Identities</h2>
+ <fieldset>
+ <gr-identities id="identities"> </gr-identities>
+ </fieldset>
+ <h2 id="MailFilters">Mail Filters</h2>
+ <fieldset class="filters">
+ <p>
+ Gerrit emails include metadata about the change to support writing
+ mail filters.
+ </p>
+ <p>
+ Here are some example Gmail queries that can be used for filters
+ or for searching through archived messages. View the
+ <a
+ href="https://test.com/user-notify.html"
+ rel="nofollow"
+ target="_blank"
+ >
+ Gerrit documentation
+ </a>
+ for the complete set of footers.
+ </p>
+ <table>
+ <tbody>
+ <tr>
+ <th>Name</th>
+ <th>Query</th>
+ </tr>
+ <tr>
+ <td>Changes requesting my review</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Reviewer: <em> Your Name </em> <
+ <em> your.email@example.com </em> >"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes requesting my attention</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Attention: <em> Your Name </em> <
+ <em> your.email@example.com </em> >"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes from a specific owner</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Owner: <em> Owner name </em> <
+ <em> owner.email@example.com </em> >"
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes targeting a specific branch</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Branch: <em> branch-name </em> "
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Changes in a specific project</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Project: <em> project-name </em> "
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Messages related to a specific Change ID</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Change-Id: <em> Change ID </em> "
+ </code>
+ </td>
+ </tr>
+ <tr>
+ <td>Messages related to a specific change number</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Change-Number: <em> change number </em> "
+ </code>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </fieldset>
+ <gr-endpoint-decorator name="settings-screen">
+ </gr-endpoint-decorator>
+ </div>
+ </div>`);
});
test('theme changing', async () => {
@@ -141,7 +551,7 @@
assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
assert.isTrue(reloadStub.calledOnce);
- element._isDark = true;
+ element.isDark = true;
await flush();
MockInteractions.tap(themeToggle);
assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
@@ -258,14 +668,14 @@
false
);
- assert.isFalse(element._prefsChanged);
+ assert.isFalse(element.prefsChanged);
const publishOnPush = valueOf('Publish comments on push', 'preferences')!
.firstElementChild!;
MockInteractions.tap(publishOnPush);
- assert.isTrue(element._prefsChanged);
+ assert.isTrue(element.prefsChanged);
stubRestApi('savePreferences').callsFake(prefs => {
assertMenusEqual(prefs.my, preferences.my);
@@ -274,8 +684,8 @@
});
// Save the change.
- await element._handleSavePreferences();
- assert.isFalse(element._prefsChanged);
+ await element.handleSavePreferences();
+ assert.isFalse(element.prefsChanged);
});
test('publish comments on push', async () => {
@@ -285,7 +695,7 @@
)!.firstElementChild!;
MockInteractions.tap(publishCommentsOnPush);
- assert.isTrue(element._prefsChanged);
+ assert.isTrue(element.prefsChanged);
stubRestApi('savePreferences').callsFake(prefs => {
assert.equal(prefs.publish_comments_on_push, true);
@@ -293,8 +703,8 @@
});
// Save the change.
- await element._handleSavePreferences();
- assert.isFalse(element._prefsChanged);
+ await element.handleSavePreferences();
+ assert.isFalse(element.prefsChanged);
});
test('set new changes work-in-progress', async () => {
@@ -304,7 +714,7 @@
)!.firstElementChild!;
MockInteractions.tap(newChangesWorkInProgress);
- assert.isTrue(element._prefsChanged);
+ assert.isTrue(element.prefsChanged);
stubRestApi('savePreferences').callsFake(prefs => {
assert.equal(prefs.work_in_progress_by_default, true);
@@ -312,37 +722,40 @@
});
// Save the change.
- await element._handleSavePreferences();
- assert.isFalse(element._prefsChanged);
+ await element.handleSavePreferences();
+ assert.isFalse(element.prefsChanged);
});
- test('add email validation', () => {
- assert.isFalse(element._isNewEmailValid('invalid email'));
- assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+ test('add email validation', async () => {
+ assert.isFalse(element.isNewEmailValid('invalid email'));
+ assert.isTrue(element.isNewEmailValid('vaguely@valid.email'));
- assert.isFalse(
- element._computeAddEmailButtonEnabled('invalid email', true)
- );
- assert.isFalse(
- element._computeAddEmailButtonEnabled('vaguely@valid.email', true)
- );
- assert.isTrue(
- element._computeAddEmailButtonEnabled('vaguely@valid.email', false)
- );
+ element.newEmail = 'invalid email';
+ element.addingEmail = true;
+ await element.updateComplete;
+ assert.isFalse(element.computeAddEmailButtonEnabled());
+ element.newEmail = 'vaguely@valid.email';
+ element.addingEmail = true;
+ await element.updateComplete;
+ assert.isFalse(element.computeAddEmailButtonEnabled());
+ element.newEmail = 'vaguely@valid.email';
+ element.addingEmail = false;
+ await element.updateComplete;
+ assert.isTrue(element.computeAddEmailButtonEnabled());
});
test('add email does not save invalid', () => {
const addEmailStub = stubAddAccountEmail(201);
- assert.isFalse(element._addingEmail);
- assert.isNotOk(element._lastSentVerificationEmail);
- element._newEmail = 'invalid email';
+ assert.isFalse(element.addingEmail);
+ assert.isNotOk(element.lastSentVerificationEmail);
+ element.newEmail = 'invalid email';
- element._handleAddEmailButton();
+ element.handleAddEmailButton();
- assert.isFalse(element._addingEmail);
+ assert.isFalse(element.addingEmail);
assert.isFalse(addEmailStub.called);
- assert.isNotOk(element._lastSentVerificationEmail);
+ assert.isNotOk(element.lastSentVerificationEmail);
assert.isFalse(addEmailStub.called);
});
@@ -350,62 +763,59 @@
test('add email does save valid', async () => {
const addEmailStub = stubAddAccountEmail(201);
- assert.isFalse(element._addingEmail);
- assert.isNotOk(element._lastSentVerificationEmail);
- element._newEmail = 'valid@email.com';
+ assert.isFalse(element.addingEmail);
+ assert.isNotOk(element.lastSentVerificationEmail);
+ element.newEmail = 'valid@email.com';
- element._handleAddEmailButton();
+ element.handleAddEmailButton();
- assert.isTrue(element._addingEmail);
+ assert.isTrue(element.addingEmail);
assert.isTrue(addEmailStub.called);
assert.isTrue(addEmailStub.called);
await addEmailStub.lastCall.returnValue;
- assert.isOk(element._lastSentVerificationEmail);
+ assert.isOk(element.lastSentVerificationEmail);
});
test('add email does not set last-email if error', async () => {
const addEmailStub = stubAddAccountEmail(500);
- assert.isNotOk(element._lastSentVerificationEmail);
- element._newEmail = 'valid@email.com';
+ assert.isNotOk(element.lastSentVerificationEmail);
+ element.newEmail = 'valid@email.com';
- element._handleAddEmailButton();
+ element.handleAddEmailButton();
assert.isTrue(addEmailStub.called);
await addEmailStub.lastCall.returnValue;
- assert.isNotOk(element._lastSentVerificationEmail);
+ assert.isNotOk(element.lastSentVerificationEmail);
});
test('emails are loaded without emailToken', () => {
- const emailEditorLoadDataStub = sinon.stub(
- element.$.emailEditor,
- 'loadData'
- );
+ const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
element.params = {
view: GerritView.SETTINGS,
} as AppElementSettingsParam;
- element.connectedCallback();
+ element.firstUpdated();
assert.isTrue(emailEditorLoadDataStub.calledOnce);
});
- test('_handleSaveChangeTable', () => {
+ test('handleSaveChangeTable', () => {
let newColumns = ['Owner', 'Project', 'Branch'];
- element._localChangeTableColumns = newColumns.slice(0);
- element._showNumber = false;
- element._handleSaveChangeTable();
+ element.localChangeTableColumns = newColumns.slice(0);
+ element.showNumber = false;
+ element.handleSaveChangeTable();
assert.deepEqual(element.prefs.change_table, newColumns);
assert.isNotOk(element.prefs.legacycid_in_change_table);
newColumns = ['Size'];
- element._localChangeTableColumns = newColumns;
- element._showNumber = true;
- element._handleSaveChangeTable();
+ element.localChangeTableColumns = newColumns;
+ element.showNumber = true;
+ element.handleSaveChangeTable();
assert.deepEqual(element.prefs.change_table, newColumns);
assert.isTrue(element.prefs.legacycid_in_change_table);
});
- test('_showHttpAuth', () => {
+ test('showHttpAuth', async () => {
const serverConfig: ServerInfo = {
...createServerInfo(),
auth: {
@@ -413,41 +823,48 @@
} as AuthInfo,
};
- assert.isTrue(element._showHttpAuth(serverConfig));
+ element.serverConfig = serverConfig;
+ await element.updateComplete;
+ assert.isTrue(element.showHttpAuth());
- serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
- assert.isTrue(element._showHttpAuth(serverConfig));
+ element.serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
+ await element.updateComplete;
+ assert.isTrue(element.showHttpAuth());
- serverConfig.auth.git_basic_auth_policy = 'LDAP';
- assert.isFalse(element._showHttpAuth(serverConfig));
+ element.serverConfig.auth.git_basic_auth_policy = 'LDAP';
+ await element.updateComplete;
+ assert.isFalse(element.showHttpAuth());
- serverConfig.auth.git_basic_auth_policy = 'OAUTH';
- assert.isFalse(element._showHttpAuth(serverConfig));
+ element.serverConfig.auth.git_basic_auth_policy = 'OAUTH';
+ await element.updateComplete;
+ assert.isFalse(element.showHttpAuth());
- assert.isFalse(element._showHttpAuth(undefined));
+ element.serverConfig = undefined;
+ await element.updateComplete;
+ assert.isFalse(element.showHttpAuth());
});
- suite('_getFilterDocsLink', () => {
+ suite('getFilterDocsLink', () => {
test('with http: docs base URL', () => {
const base = 'http://example.com/';
- const result = element._getFilterDocsLink(base);
+ const result = element.getFilterDocsLink(base);
assert.equal(result, 'http://example.com/user-notify.html');
});
test('with http: docs base URL without slash', () => {
const base = 'http://example.com';
- const result = element._getFilterDocsLink(base);
+ const result = element.getFilterDocsLink(base);
assert.equal(result, 'http://example.com/user-notify.html');
});
test('with https: docs base URL', () => {
const base = 'https://example.com/';
- const result = element._getFilterDocsLink(base);
+ const result = element.getFilterDocsLink(base);
assert.equal(result, 'https://example.com/user-notify.html');
});
test('without docs base URL', () => {
- const result = element._getFilterDocsLink(null);
+ const result = element.getFilterDocsLink(null);
assert.equal(
result,
'https://gerrit-review.googlesource.com/' +
@@ -457,7 +874,7 @@
test('ignores non HTTP links', () => {
const base = 'javascript://alert("evil");';
- const result = element._getFilterDocsLink(base);
+ const result = element.getFilterDocsLink(base);
assert.equal(
result,
'https://gerrit-review.googlesource.com/' +
@@ -474,7 +891,7 @@
let emailEditorLoadDataStub: sinon.SinonStub;
setup(() => {
- emailEditorLoadDataStub = sinon.stub(element.$.emailEditor, 'loadData');
+ emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
confirmEmailStub = stubRestApi('confirmEmail').returns(
new Promise(resolve => {
resolveConfirm = resolve;
@@ -482,7 +899,7 @@
);
element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
- element.connectedCallback();
+ element.firstUpdated();
});
test('it is used to confirm email via rest API', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index d585252..99b72fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -147,15 +147,7 @@
background-color: var(--view-background-color);
width: 100%;
display: block;
-
- /* You have to also repeat everything from shared-styles here, because
- you can only *replace* --iron-autogrow-textarea vars as a whole. */
- --iron-autogrow-textarea: {
- box-sizing: border-box;
- padding: var(--spacing-m);
- overflow-y: hidden;
- white-space: pre;
- }
+ --iron-autogrow-textarea_-_padding: var(--spacing-m);
}
.editButtons {
display: flex;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 18ea5de..79c73f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -25,6 +25,7 @@
PrimaryActionKey,
RevisionActions,
} from '../../../api/change-actions';
+import {PropertyDeclaration} from 'lit';
export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
__key: string;
@@ -40,7 +41,6 @@
ChangeActions: Record<string, string>;
ActionType: Record<string, string>;
primaryActionKeys: string[];
- push(propName: 'primaryActionKeys', value: string): void;
hideQuickApproveAction(): void;
setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
setActionPriority(
@@ -57,6 +57,11 @@
value: UIActionInfo[T]
): void;
getActionDetails(actionName: string): ActionInfo | undefined;
+ requestUpdate(
+ name?: PropertyKey,
+ oldValue?: unknown,
+ options?: PropertyDeclaration
+ ): void;
}
export class GrChangeActionsInterface implements ChangeActionsPluginApi {
@@ -111,7 +116,8 @@
return;
}
- el.push('primaryActionKeys', key);
+ el.primaryActionKeys.push(key);
+ el.requestUpdate();
}
removePrimaryActionKey(key: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index fceb518..8d1c770 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -32,10 +32,10 @@
ChangeActionsPluginApi,
PrimaryActionKey,
} from '../../../api/change-actions';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
import {GrButton} from '../gr-button/gr-button';
import {IronIconElement} from '@polymer/iron-icon';
import {ChangeViewChangeInfo} from '../../../types/common';
+import {GrDropdown} from '../gr-dropdown/gr-dropdown';
suite('gr-change-actions-js-api-interface tests', () => {
let element: GrChangeActions;
@@ -77,7 +77,7 @@
element = await fixture<GrChangeActions>(html`
<gr-change-actions></gr-change-actions>
`);
- sinon.stub(element, '_editStatusChanged');
+ sinon.stub(element, 'editStatusChanged');
element.change = {} as ChangeViewChangeInfo;
element._hasKnownChainState = false;
window.Gerrit.install(
@@ -118,22 +118,21 @@
assert.deepEqual(element.primaryActionKeys, []);
});
- test('action buttons', () => {
+ test('action buttons', async () => {
const key = changeActions.add(ActionType.REVISION, 'Bork!');
const handler = sinon.spy();
changeActions.addTapListener(key, handler);
- flush();
- MockInteractions.tap(
- queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
- );
+ await element.updateComplete;
+ queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+ await element.updateComplete;
assert(handler.calledOnce);
changeActions.removeTapListener(key, handler);
- MockInteractions.tap(
- queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
- );
+ await element.updateComplete;
+ queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+ await element.updateComplete;
assert(handler.calledOnce);
changeActions.remove(key);
- flush();
+ await element.updateComplete;
assert.isUndefined(
query<GrButton>(element, `[data-action-key="${key}"]`)
);
@@ -141,7 +140,7 @@
test('action button properties', async () => {
const key = changeActions.add(ActionType.REVISION, 'Bork!');
- flush();
+ await element.updateComplete;
const button = queryAndAssert<GrButton>(
element,
`[data-action-key="${key}"]`
@@ -153,7 +152,7 @@
changeActions.setTitle(key, 'Yo hint');
changeActions.setEnabled(key, false);
changeActions.setIcon(key, 'pupper');
- await flush();
+ await element.updateComplete;
assert.equal(button.getAttribute('data-label'), 'Yo');
assert.equal(button.parentElement!.getAttribute('title'), 'Yo hint');
assert.isTrue(button.disabled);
@@ -165,39 +164,44 @@
test('hide action buttons', async () => {
const key = changeActions.add(ActionType.REVISION, 'Bork!');
- await flush();
+ await element.updateComplete;
let button = query<GrButton>(element, `[data-action-key="${key}"]`);
assert.isOk(button);
assert.isFalse(button!.hasAttribute('hidden'));
changeActions.setActionHidden(ActionType.REVISION, key, true);
- flush();
+ await element.updateComplete;
button = query<GrButton>(element, `[data-action-key="${key}"]`);
assert.isNotOk(button);
});
test('move action button to overflow', async () => {
const key = changeActions.add(ActionType.REVISION, 'Bork!');
- await flush();
- assert.isTrue(element.$.moreActions.hidden);
+ await element.updateComplete;
+ assert.isTrue(queryAndAssert<GrDropdown>(element, '#moreActions').hidden);
assert.isOk(
queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
);
changeActions.setActionOverflow(ActionType.REVISION, key, true);
- await flush();
+ await element.updateComplete;
assert.isNotOk(query<GrButton>(element, `[data-action-key="${key}"]`));
- assert.isFalse(element.$.moreActions.hidden);
- assert.strictEqual(element.$.moreActions.items![0].name, 'Bork!');
+ assert.isFalse(
+ queryAndAssert<GrDropdown>(element, '#moreActions').hidden
+ );
+ assert.strictEqual(
+ queryAndAssert<GrDropdown>(element, '#moreActions').items![0].name,
+ 'Bork!'
+ );
});
- test('change actions priority', () => {
+ test('change actions priority', async () => {
const key1 = changeActions.add(ActionType.REVISION, 'Bork!');
const key2 = changeActions.add(ActionType.CHANGE, 'Squanch?');
- flush();
+ await element.updateComplete;
let buttons = queryAll<GrButton>(element, '[data-action-key]');
assert.equal(buttons[0].getAttribute('data-action-key'), key1);
assert.equal(buttons[1].getAttribute('data-action-key'), key2);
changeActions.setActionPriority(ActionType.REVISION, key1, 10);
- flush();
+ await element.updateComplete;
buttons = queryAll<GrButton>(element, '[data-action-key]');
assert.equal(buttons[0].getAttribute('data-action-key'), key2);
assert.equal(buttons[1].getAttribute('data-action-key'), key1);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index e426e66..c72fc68 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -40,7 +40,11 @@
GrRangedCommentLayer,
} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {
+ DiffViewMode,
+ RenderPreferences,
+ RenderProgressEventDetail,
+} from '../../../api/diff';
import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
import {
@@ -49,7 +53,7 @@
hideInContextControl,
} from '../gr-diff/gr-diff-group';
import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fire} from '../../../utils/event-util';
import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -58,6 +62,12 @@
const COMMIT_MSG_PATH = '/COMMIT_MSG';
const COMMIT_MSG_LINE_LENGTH = 72;
+declare global {
+ interface HTMLElementEventMap {
+ 'render-progress': CustomEvent<RenderProgressEventDetail>;
+ }
+}
+
export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
return prefs.font_size * 4;
}
@@ -97,19 +107,22 @@
}
/**
- * Fired when the diff begins rendering.
+ * Fired when the diff begins rendering - both for full renders and for
+ * partial rerenders.
*
* @event render-start
*/
/**
- * Fired whenever a new chunk of lines has been rendered synchronously.
+ * Fired whenever a new chunk of lines has been rendered synchronously - this
+ * only happens for full renders.
*
* @event render-progress
*/
/**
- * Fired when the diff finishes rendering text content.
+ * Fired when the diff finishes rendering text content - both for full
+ * renders and for partial rerenders.
*
* @event render-content
*/
@@ -282,7 +295,7 @@
if (this.isImageDiff) {
(this._builder as GrDiffBuilderImage).renderDiff();
}
- fireEvent(this, 'render-content');
+ afterNextRender(this, () => fireEvent(this, 'render-content'));
})
);
return (
@@ -393,8 +406,7 @@
lineRange.end_line - lineRange.start_line + 1
)
);
- this._builder.replaceGroup(group, newGroups);
- setTimeout(() => fireEvent(this, 'render-content'), 1);
+ this.replaceGroup(group, newGroups);
}
/**
@@ -410,8 +422,16 @@
newGroups: readonly GrDiffGroup[]
) {
if (!this._builder) return;
+ fireEvent(this, 'render-start');
+ const linesRendered = newGroups.reduce(
+ (sum, group) => sum + group.lines.length,
+ 0
+ );
this._builder.replaceGroup(contextGroup, newGroups);
- setTimeout(() => fireEvent(this, 'render-content'), 1);
+ afterNextRender(this, () => {
+ fire(this, 'render-progress', {linesRendered});
+ fireEvent(this, 'render-content');
+ });
}
cancel() {
@@ -505,7 +525,9 @@
addGroup(group: GrDiffGroup) {
if (!this._builder) return;
this._builder.addGroups([group]);
- fireEvent(this, 'render-progress');
+ afterNextRender(this, () =>
+ fire(this, 'render-progress', {linesRendered: group.lines.length})
+ );
}
_createIntralineLayer(): DiffLayer {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 0ad21b0..6cbbc62 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -661,6 +661,7 @@
});
test('render-start and render-content are fired', async () => {
+ await new Promise(resolve => afterNextRender(element, resolve));
const firedEventTypes = element.dispatchEvent.getCalls()
.map(c => c.args[0].type);
assert.include(firedEventTypes, 'render-start');
@@ -738,7 +739,6 @@
});
test('unhideLine shows the line with context', async () => {
- const clock = sinon.useFakeTimers();
element.dispatchEvent.reset();
element.unhideLine(4, Side.LEFT);
@@ -759,8 +759,7 @@
assert.include(diffRows[8].textContent, 'after');
assert.include(diffRows[9].textContent, 'unchanged 11');
- clock.tick(1);
- await flush();
+ await new Promise(resolve => afterNextRender(element, resolve));
const firedEventTypes = element.dispatchEvent.getCalls()
.map(c => c.args[0].type);
assert.include(firedEventTypes, 'render-content');
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 9b093af..f48d673 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -23,6 +23,7 @@
import {createDiff} from '../../../test/test-data-generators.js';
import {createDefaultDiffPrefs} from '../../../constants/constants.js';
import {GrDiffCursor} from './gr-diff-cursor.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
suite('gr-diff-cursor tests', () => {
let cursor;
@@ -159,7 +160,7 @@
};
diffElement.diff = diff;
- await flush();
+ await new Promise(resolve => afterNextRender(diffElement, resolve));
cursor._updateStops();
const chunks = Array.from(diffElement.root.querySelectorAll(
@@ -213,16 +214,10 @@
suite('unified diff', () => {
setup(async () => {
- const promise = mockPromise();
- // We must allow the diff to re-render after setting the viewMode.
- const renderHandler = function() {
- diffElement.removeEventListener('render', renderHandler);
- cursor.reInitCursor();
- promise.resolve();
- };
- diffElement.addEventListener('render', renderHandler);
diffElement.viewMode = 'UNIFIED_DIFF';
- await promise;
+ // We must allow the diff to re-render after setting the viewMode.
+ await new Promise(resolve => afterNextRender(diffElement, resolve));
+ cursor.reInitCursor();
});
test('diff cursor functionality (unified)', () => {
@@ -457,19 +452,13 @@
scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
});
- const promise = mockPromise();
- function renderHandler() {
- diffElement.removeEventListener('render', renderHandler);
- cursor.reInitCursor();
- assert.isFalse(moveToNumStub.called);
- assert.isTrue(moveToChunkStub.called);
- assert.equal(scrollBehaviorDuringMove, 'never');
- assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
- promise.resolve();
- }
- diffElement.addEventListener('render', renderHandler);
diffElement._diffChanged(createDiff());
- await promise;
+ await new Promise(resolve => afterNextRender(diffElement, resolve));
+ cursor.reInitCursor();
+ assert.isFalse(moveToNumStub.called);
+ assert.isTrue(moveToChunkStub.called);
+ assert.equal(scrollBehaviorDuringMove, 'never');
+ assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
});
test('initialLineNumber provided', async () => {
@@ -479,24 +468,18 @@
scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
});
const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
- const promise = mockPromise();
- function renderHandler() {
- diffElement.removeEventListener('render', renderHandler);
- cursor.reInitCursor();
- assert.isFalse(moveToChunkStub.called);
- assert.isTrue(moveToNumStub.called);
- assert.equal(moveToNumStub.lastCall.args[0], 10);
- assert.equal(moveToNumStub.lastCall.args[1], 'right');
- assert.equal(scrollBehaviorDuringMove, 'keep-visible');
- assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
- promise.resolve();
- }
- diffElement.addEventListener('render', renderHandler);
cursor.initialLineNumber = 10;
cursor.side = 'right';
diffElement._diffChanged(createDiff());
- await promise;
+ await new Promise(resolve => afterNextRender(diffElement, resolve));
+ cursor.reInitCursor();
+ assert.isFalse(moveToChunkStub.called);
+ assert.isTrue(moveToNumStub.called);
+ assert.equal(moveToNumStub.lastCall.args[0], 10);
+ assert.equal(moveToNumStub.lastCall.args[1], 'right');
+ assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+ assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
});
test('getTargetDiffElement', () => {
@@ -548,7 +531,7 @@
end_line: 6,
end_character: 1,
};
- diffElement.$.highlights.selectedRange = {
+ diffElement.highlights.selectedRange = {
side: 'right',
range: someRange,
};
@@ -618,7 +601,7 @@
MockInteractions.tap(diffElement.shadowRoot
.querySelector('gr-context-controls').shadowRoot
.querySelector('.showContext'));
- await flush();
+ await new Promise(resolve => afterNextRender(diffElement, resolve));
assert.isTrue(cursor._updateStops.called);
});
@@ -664,6 +647,7 @@
diffElements[0].diff = createDiff();
diffElements[2].diff = createDiff();
await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
+ await new Promise(resolve => afterNextRender(diffElements[0], resolve));
const lastLine = diffElements[0].diff.meta_b.lines;
@@ -685,6 +669,7 @@
// Diff 1 finishing to load
diffElements[1].diff = createDiff();
await diffRenderedPromises[1];
+ await new Promise(resolve => afterNextRender(diffElements[0], resolve));
// Now we can go down
cursor.moveDown();
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 2ee6c9f..0714645 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -1,43 +1,25 @@
/**
* @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/shared-styles';
import '../gr-selection-action-box/gr-selection-action-box';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-highlight_html';
import {GrAnnotation} from './gr-annotation';
import {normalize} from './gr-range-normalizer';
import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property} from '@polymer/decorators';
import {Side} from '../../../constants/constants';
import {CommentRange} from '../../../types/common';
import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
import {FILE} from '../gr-diff/gr-diff-line';
import {
getLineElByChild,
getLineNumberByChild,
- getRange,
- getSide,
getSideByLineEl,
GrDiffThreadElement,
} from '../gr-diff/gr-diff-utils';
import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
interface SidedRange {
side: Side;
@@ -56,51 +38,65 @@
end: NormalizedPosition | null;
}
-@customElement('gr-diff-highlight')
-export class GrDiffHighlight extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+ getContentTdByLineEl(lineEl?: Element): Element | null;
+}
- @property({type: Array, notify: true})
- commentRanges: SidedRange[] = [];
-
- @property({type: Boolean})
- loggedIn?: boolean;
-
- @property({type: Object})
- _cachedDiffBuilder?: GrDiffBuilderElement;
-
- @property({type: Object, notify: true})
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
selectedRange?: SidedRange;
+ private diffBuilder?: DiffBuilderInterface;
+
+ private diffTable?: HTMLElement;
+
private selectionChangeTask?: DelayedTask;
- constructor() {
- super();
- this.addEventListener('comment-thread-mouseleave', e =>
- this._handleCommentThreadMouseleave(e)
+ init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+ this.cleanup();
+
+ this.diffTable = diffTable;
+ this.diffBuilder = diffBuilder;
+
+ diffTable.addEventListener(
+ 'comment-thread-mouseleave',
+ this.handleCommentThreadMouseleave
);
- this.addEventListener('comment-thread-mouseenter', e =>
- this._handleCommentThreadMouseenter(e)
+ diffTable.addEventListener(
+ 'comment-thread-mouseenter',
+ this.handleCommentThreadMouseenter
);
- this.addEventListener('create-comment-requested', e =>
- this._handleRangeCommentRequest(e)
+ diffTable.addEventListener(
+ 'create-comment-requested',
+ this.handleRangeCommentRequest
);
}
- override disconnectedCallback() {
+ cleanup() {
this.selectionChangeTask?.cancel();
- super.disconnectedCallback();
- }
-
- get diffBuilder() {
- if (!this._cachedDiffBuilder) {
- this._cachedDiffBuilder = this.querySelector(
- 'gr-diff-builder'
- ) as GrDiffBuilderElement;
+ if (this.diffTable) {
+ this.diffTable.removeEventListener(
+ 'comment-thread-mouseleave',
+ this.handleCommentThreadMouseleave
+ );
+ this.diffTable.removeEventListener(
+ 'comment-thread-mouseenter',
+ this.handleCommentThreadMouseenter
+ );
+ this.diffTable.removeEventListener(
+ 'create-comment-requested',
+ this.handleRangeCommentRequest
+ );
}
- return this._cachedDiffBuilder;
}
/**
@@ -129,18 +125,17 @@
// removed.
// If you wait longer than 50 ms, then you don't properly catch a very
// quick 'c' press after the selection change. If you wait less than 10
- // ms, then you will have about 50 _handleSelection calls when doing a
+ // ms, then you will have about 50 handleSelection() calls when doing a
// simple drag for select.
this.selectionChangeTask = debounce(
this.selectionChangeTask,
- () => this._handleSelection(selection, isMouseUp),
+ () => this.handleSelection(selection, isMouseUp),
10
);
}
- _getThreadEl(e: Event): GrDiffThreadElement | null {
- const path = (dom(e) as EventApi).path || [];
- for (const pathEl of path) {
+ private getThreadEl(e: Event): GrDiffThreadElement | null {
+ for (const pathEl of e.composedPath()) {
if (
pathEl instanceof HTMLElement &&
pathEl.classList.contains('comment-thread')
@@ -151,130 +146,74 @@
return null;
}
- _toggleRangeElHighlight(
- threadEl: GrDiffThreadElement,
+ private toggleRangeElHighlight(
+ threadEl: GrDiffThreadElement | null,
highlightRange = false
) {
- // We don't want to re-create the line just for highlighting the range which
- // is creating annoying bugs: @see Issue 12934
- // As gr-ranged-comment-layer now does not notify the layer re-render and
- // lack of access to the thread or the lineEl from the ranged-comment-layer,
- // need to update range class for styles here.
- let curNode: HTMLElement | null = threadEl.assignedSlot;
- while (curNode) {
- if (curNode.nodeName === 'TABLE') break;
- curNode = curNode.parentElement;
- }
- if (curNode?.querySelectorAll) {
- if (highlightRange) {
- const rangeNodes = curNode.querySelectorAll(
- `.range.${strToClassName(threadEl.rootId)}`
- );
- rangeNodes.forEach(rangeNode => {
- rangeNode.classList.add('rangeHoverHighlight');
- });
- const hintNode = threadEl.parentElement?.querySelector(
- `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
- );
- if (hintNode) {
- hintNode.shadowRoot
- ?.querySelectorAll('.rangeHighlight')
- .forEach(highlightNode =>
- highlightNode.classList.add('rangeHoverHighlight')
- );
- }
- } else {
- const rangeNodes = curNode.querySelectorAll(
- `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
- );
- rangeNodes.forEach(rangeNode => {
- rangeNode.classList.remove('rangeHoverHighlight');
- });
- const hintNode = threadEl.parentElement?.querySelector(
- `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
- );
- if (hintNode) {
- hintNode.shadowRoot
- ?.querySelectorAll('.rangeHoverHighlight')
- .forEach(highlightNode =>
- highlightNode.classList.remove('rangeHoverHighlight')
- );
- }
- }
- }
- }
-
- _handleCommentThreadMouseenter(e: Event) {
- const threadEl = this._getThreadEl(e)!;
- const index = this._indexForThreadEl(threadEl);
-
- if (index !== undefined) {
- this.set(['commentRanges', index, 'hovering'], true);
- }
-
- this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
- }
-
- _handleCommentThreadMouseleave(e: Event) {
- const threadEl = this._getThreadEl(e)!;
- const index = this._indexForThreadEl(threadEl);
-
- if (index !== undefined) {
- this.set(['commentRanges', index, 'hovering'], false);
- }
-
- this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
- }
-
- _indexForThreadEl(threadEl: HTMLElement) {
- const side = getSide(threadEl);
- const range = getRange(threadEl);
- if (!side || !range) return undefined;
- return this._indexOfCommentRange(side, range);
- }
-
- _indexOfCommentRange(side: Side, range: CommentRange) {
- function rangesEqual(a: CommentRange, b: CommentRange) {
- if (!a && !b) {
- return true;
- }
- if (!a || !b) {
- return false;
- }
- return (
- a.start_line === b.start_line &&
- a.start_character === b.start_character &&
- a.end_line === b.end_line &&
- a.end_character === b.end_character
+ const rootId = threadEl?.rootId;
+ if (!rootId) return;
+ if (!this.diffTable) return;
+ if (highlightRange) {
+ const selector = `.range.${strToClassName(rootId)}`;
+ const rangeNodes = this.diffTable.querySelectorAll(selector);
+ rangeNodes.forEach(rangeNode => {
+ rangeNode.classList.add('rangeHoverHighlight');
+ });
+ const hintNode = this.diffTable.querySelector(
+ `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
);
+ hintNode?.shadowRoot
+ ?.querySelectorAll('.rangeHighlight')
+ .forEach(highlightNode =>
+ highlightNode.classList.add('rangeHoverHighlight')
+ );
+ } else {
+ const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+ const rangeNodes = this.diffTable.querySelectorAll(selector);
+ rangeNodes.forEach(rangeNode => {
+ rangeNode.classList.remove('rangeHoverHighlight');
+ });
+ const hintNode = this.diffTable.querySelector(
+ `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+ );
+ hintNode?.shadowRoot
+ ?.querySelectorAll('.rangeHoverHighlight')
+ .forEach(highlightNode =>
+ highlightNode.classList.remove('rangeHoverHighlight')
+ );
}
-
- return this.commentRanges.findIndex(
- commentRange =>
- commentRange.side === side && rangesEqual(commentRange.range, range)
- );
}
+ private handleCommentThreadMouseenter = (e: Event) => {
+ const threadEl = this.getThreadEl(e);
+ this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+ };
+
+ private handleCommentThreadMouseleave = (e: Event) => {
+ const threadEl = this.getThreadEl(e);
+ this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+ };
+
/**
* Get current normalized selection.
* Merges multiple ranges, accounts for triple click, accounts for
* syntax highligh, convert native DOM Range objects to Gerrit concepts
* (line, side, etc).
*/
- _getNormalizedRange(selection: Selection | Range) {
+ private getNormalizedRange(selection: Selection | Range) {
/* On Safari the ShadowRoot.getSelection() isn't there and the only thing
we can get is a single Range */
if (selection instanceof Range) {
- return this._normalizeRange(selection);
+ return this.normalizeRange(selection);
}
const rangeCount = selection.rangeCount;
if (rangeCount === 0) {
return null;
} else if (rangeCount === 1) {
- return this._normalizeRange(selection.getRangeAt(0));
+ return this.normalizeRange(selection.getRangeAt(0));
} else {
- const startRange = this._normalizeRange(selection.getRangeAt(0));
- const endRange = this._normalizeRange(
+ const startRange = this.normalizeRange(selection.getRangeAt(0));
+ const endRange = this.normalizeRange(
selection.getRangeAt(rangeCount - 1)
);
return {
@@ -289,15 +228,15 @@
*
* @return fixed normalized range
*/
- _normalizeRange(domRange: Range): NormalizedRange {
+ private normalizeRange(domRange: Range): NormalizedRange {
const range = normalize(domRange);
- return this._fixTripleClickSelection(
+ return this.fixTripleClickSelection(
{
- start: this._normalizeSelectionSide(
+ start: this.normalizeSelectionSide(
range.startContainer,
range.startOffset
),
- end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
+ end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
},
domRange
);
@@ -313,7 +252,7 @@
* @param domRange DOM Range object
* @return fixed normalized range
*/
- _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+ private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
if (!range.start) {
// Selection outside of current diff.
return range;
@@ -334,7 +273,7 @@
end.column === 0 &&
end.line === start.line + 1;
const content = domRange.cloneContents().querySelector('.contentText');
- const lineLength = (content && this._getLength(content)) || 0;
+ const lineLength = (content && this.getLength(content)) || 0;
if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
// Move the selection to the end of the previous line.
range.end = {
@@ -355,12 +294,14 @@
* @param node td.content child
* @param offset offset within node
*/
- _normalizeSelectionSide(
+ private normalizeSelectionSide(
node: Node | null,
offset: number
): NormalizedPosition | null {
let column;
- if (!node || !this.contains(node)) return null;
+ if (!this.diffTable) return null;
+ if (!this.diffBuilder) return null;
+ if (!node || !this.diffTable.contains(node)) return null;
const lineEl = getLineElByChild(node);
if (!lineEl) return null;
const side = getSideByLineEl(lineEl);
@@ -376,10 +317,10 @@
} else {
const thread = contentTd.querySelector('.comment-thread');
if (thread?.contains(node)) {
- column = this._getLength(contentText);
+ column = this.getLength(contentText);
node = contentText;
} else {
- column = this._convertOffsetToColumn(node, offset);
+ column = this.convertOffsetToColumn(node, offset);
}
}
@@ -398,7 +339,8 @@
* collapsed section, so don't need to worry about this case for
* positioning the tooltip.
*/
- _positionActionBox(
+ // visible for testing
+ positionActionBox(
actionBox: GrSelectionActionBox,
startLine: number,
range: Text | Element | Range
@@ -412,7 +354,7 @@
actionBox.placeBelow(range);
}
- _isRangeValid(range: NormalizedRange | null) {
+ private isRangeValid(range: NormalizedRange | null) {
if (!range || !range.start || !range.start.node || !range.end) {
return false;
}
@@ -425,15 +367,16 @@
);
}
- _handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+ // visible for testing
+ handleSelection(selection: Selection | Range, isMouseUp: boolean) {
/* On Safari, the selection events may return a null range that should
be ignored */
- if (!selection) {
- return;
- }
- const normalizedRange = this._getNormalizedRange(selection);
- if (!this._isRangeValid(normalizedRange)) {
- this._removeActionBox();
+ if (!selection) return;
+ if (!this.diffTable) return;
+
+ const normalizedRange = this.getNormalizedRange(selection);
+ if (!this.isRangeValid(normalizedRange)) {
+ this.removeActionBox();
return;
}
/* On Safari the ShadowRoot.getSelection() isn't there and the only thing
@@ -463,8 +406,8 @@
// start.column with the content length), we just check if the selection
// is empty to see that it's at the end of a line.
const content = domRange.cloneContents().querySelector('.contentText');
- if (isMouseUp && this._getLength(content) === 0) {
- this._fireCreateRangeComment(start.side, {
+ if (isMouseUp && this.getLength(content) === 0) {
+ this.fireCreateRangeComment(start.side, {
start_line: start.line,
start_character: 0,
end_line: start.line,
@@ -474,10 +417,10 @@
return;
}
- let actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
+ let actionBox = this.diffTable.querySelector('gr-selection-action-box');
if (!actionBox) {
actionBox = document.createElement('gr-selection-action-box');
- this.root!.insertBefore(actionBox, this.root!.firstElementChild);
+ this.diffTable.appendChild(actionBox);
}
this.selectedRange = {
range: {
@@ -489,10 +432,10 @@
side: start.side,
};
if (start.line === end.line) {
- this._positionActionBox(actionBox, start.line, domRange);
+ this.positionActionBox(actionBox, start.line, domRange);
} else if (start.node instanceof Text) {
if (start.column) {
- this._positionActionBox(
+ this.positionActionBox(
actionBox,
start.line,
start.node.splitText(start.column)
@@ -505,44 +448,41 @@
(start.node.firstChild instanceof Element ||
start.node.firstChild instanceof Text)
) {
- this._positionActionBox(actionBox, start.line, start.node.firstChild);
+ this.positionActionBox(actionBox, start.line, start.node.firstChild);
} else if (start.node instanceof Element || start.node instanceof Text) {
- this._positionActionBox(actionBox, start.line, start.node);
+ this.positionActionBox(actionBox, start.line, start.node);
} else {
console.warn('Failed to position comment action box.');
- this._removeActionBox();
+ this.removeActionBox();
}
}
- _fireCreateRangeComment(side: Side, range: CommentRange) {
- this.dispatchEvent(
+ private fireCreateRangeComment(side: Side, range: CommentRange) {
+ this.diffTable?.dispatchEvent(
new CustomEvent('create-range-comment', {
detail: {side, range},
composed: true,
bubbles: true,
})
);
- this._removeActionBox();
+ this.removeActionBox();
}
- _handleRangeCommentRequest(e: Event) {
+ private handleRangeCommentRequest = (e: Event) => {
e.stopPropagation();
- if (!this.selectedRange) {
- throw Error('Selected Range is needed for new range comment!');
- }
+ assertIsDefined(this.selectedRange, 'selectedRange');
const {side, range} = this.selectedRange;
- this._fireCreateRangeComment(side, range);
- }
+ this.fireCreateRangeComment(side, range);
+ };
- _removeActionBox() {
+ // visible for testing
+ removeActionBox() {
this.selectedRange = undefined;
- const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
- if (actionBox) {
- this.root!.removeChild(actionBox);
- }
+ const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+ if (actionBox) actionBox.remove();
}
- _convertOffsetToColumn(el: Node, offset: number) {
+ private convertOffsetToColumn(el: Node, offset: number) {
if (el instanceof Element && el.classList.contains('content')) {
return offset;
}
@@ -552,7 +492,7 @@
) {
if (el.previousSibling) {
el = el.previousSibling;
- offset += this._getLength(el);
+ offset += this.getLength(el);
} else {
el = el.parentElement!;
}
@@ -566,18 +506,24 @@
*
* @param node this is sometimes passed as null.
*/
- _getLength(node: Node | null): number {
+ // visible for testing
+ getLength(node: Node | null): number {
if (node === null) return 0;
if (node instanceof Element && node.classList.contains('content')) {
- return this._getLength(queryAndAssert(node, '.contentText'));
+ return this.getLength(queryAndAssert(node, '.contentText'));
} else {
return GrAnnotation.getLength(node);
}
}
}
+export interface CreateRangeCommentEventDetail {
+ side: Side;
+ range: CommentRange;
+}
+
declare global {
- interface HTMLElementTagNameMap {
- 'gr-diff-highlight': GrDiffHighlight;
+ interface HTMLElementEventMap {
+ 'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
deleted file mode 100644
index 5a6cb1c..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- :host {
- position: relative;
- }
- gr-selection-action-box {
- /**
- * Needs z-index to appear above wrapped content, since it's inserted
- * into DOM before it.
- */
- z-index: 10;
- }
- </style>
- <div class="contentWrapper">
- <slot></slot>
- </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
deleted file mode 100644
index 4c1295f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ /dev/null
@@ -1,589 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-highlight.js';
-import {_getTextOffset} from './gr-range-normalizer.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<style>
- .tab-indicator:before {
- color: #C62828;
- /* >> character */
- content: '\\00BB';
- }
- </style>
- <gr-diff-highlight>
- <table id="diffTable">
-
- <tbody class="section both">
- <tr class="diff-row side-by-side" left-type="both" right-type="both">
- <td class="left lineNum" data-value="1"></td>
- <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
- <td class="right lineNum" data-value="1"></td>
- <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
- </tr>
- </tbody>
-
- <tbody class="section delta">
- <tr class="diff-row side-by-side" left-type="remove" right-type="add">
- <td class="left lineNum" data-value="2"></td>
- <!-- Next tag is formatted to eliminate zero-length text nodes. -->
- <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
- <td class="right lineNum" data-value="2"></td>
- <!-- Next tag is formatted to eliminate zero-length text nodes. -->
- <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum, <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam, sit, quod</div></td>
- </tr>
- </tbody>
-
-<tbody class="section both">
- <tr class="diff-row side-by-side" left-type="both" right-type="both">
- <td class="left lineNum" data-value="138"></td>
- <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
- <td class="right lineNum" data-value="119"></td>
- <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
- </tr>
- </tbody>
-
- <tbody class="section delta">
- <tr class="diff-row side-by-side" left-type="remove" right-type="add">
- <td class="left lineNum" data-value="140"></td>
- <!-- Next tag is formatted to eliminate zero-length text nodes. -->
- <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
- [Yet another random diff thread content here]
- </div></td>
- <td class="right lineNum" data-value="120"></td>
- <!-- Next tag is formatted to eliminate zero-length text nodes. -->
- <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum, <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam, sit, quod</div></td>
- </tr>
- </tbody>
-
- <tbody class="section both">
- <tr class="diff-row side-by-side" left-type="both" right-type="both">
- <td class="left lineNum" data-value="141"></td>
- <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
- <td class="right lineNum" data-value="130"></td>
- <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
- </tr>
- </tbody>
-
- <tbody class="section contextControl">
- <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
- <td class="left contextLineNum"></td>
- <td>
- <gr-button>+10↑</gr-button>
- -
- <gr-button>Show 21 common lines</gr-button>
- -
- <gr-button>+10↓</gr-button>
- </td>
- <td class="right contextLineNum"></td>
- <td>
- <gr-button>+10↑</gr-button>
- -
- <gr-button>Show 21 common lines</gr-button>
- -
- <gr-button>+10↓</gr-button>
- </td>
- </tr>
- </tbody>
-
- <tbody class="section delta total">
- <tr class="diff-row side-by-side" left-type="blank" right-type="add">
- <td class="left"></td>
- <td class="blank"></td>
- <td class="right lineNum" data-value="146"></td>
- <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
- </tr>
- </tbody>
-
- <tbody class="section both">
- <tr class="diff-row side-by-side" left-type="both" right-type="both">
- <td class="left lineNum" data-value="165"></td>
- <td class="content both"><div class="contentText"></div></td>
- <td class="right lineNum" data-value="147"></td>
- <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
- </tr>
- </tbody>
-
- </table>
- </gr-diff-highlight>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-highlight', () => {
- let element;
-
- setup(() => {
- element = basicFixture.instantiate()[1];
- });
-
- suite('comment events', () => {
- let builder;
-
- setup(() => {
- builder = {
- getContentsByLineRange: sinon.stub().returns([]),
- getLineElByChild: sinon.stub().returns({}),
- getSideByLineEl: sinon.stub().returns('other-side'),
- };
- element._cachedDiffBuilder = builder;
- });
-
- test('comment-thread-mouseenter from line comments is ignored', () => {
- const threadEl = document.createElement('div');
- threadEl.className = 'comment-thread';
- threadEl.setAttribute('diff-side', 'right');
- threadEl.setAttribute('line-num', 3);
- element.appendChild(threadEl);
- element.commentRanges = [{side: 'right'}];
-
- sinon.stub(element, 'set');
- threadEl.dispatchEvent(new CustomEvent(
- 'comment-thread-mouseenter', {bubbles: true, composed: true}));
- assert.isFalse(element.set.called);
- });
-
- test('comment-thread-mouseenter from ranged comment causes set', () => {
- const threadEl = document.createElement('div');
- threadEl.className = 'comment-thread';
- threadEl.setAttribute('diff-side', 'right');
- threadEl.setAttribute('line-num', 3);
- threadEl.setAttribute('range', JSON.stringify({
- start_line: 3,
- start_character: 4,
- end_line: 5,
- end_character: 6,
- }));
- element.appendChild(threadEl);
- element.commentRanges = [{side: 'right', range: {
- start_line: 3,
- start_character: 4,
- end_line: 5,
- end_character: 6,
- }}];
-
- sinon.stub(element, 'set');
- threadEl.dispatchEvent(new CustomEvent(
- 'comment-thread-mouseenter', {bubbles: true, composed: true}));
- assert.isTrue(element.set.called);
- const args = element.set.lastCall.args;
- assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
- assert.deepEqual(args[1], true);
- });
-
- test('comment-thread-mouseleave from line comments is ignored', () => {
- const threadEl = document.createElement('div');
- threadEl.className = 'comment-thread';
- threadEl.setAttribute('diff-side', 'right');
- threadEl.setAttribute('line-num', 3);
- element.appendChild(threadEl);
- element.commentRanges = [{side: 'right'}];
-
- sinon.stub(element, 'set');
- threadEl.dispatchEvent(new CustomEvent(
- 'comment-thread-mouseleave', {bubbles: true, composed: true}));
- assert.isFalse(element.set.called);
- });
-
- test(`create-range-comment for range when create-comment-requested
- is fired`, () => {
- sinon.stub(element, '_removeActionBox');
- element.selectedRange = {
- side: 'left',
- range: {
- start_line: 7,
- start_character: 11,
- end_line: 24,
- end_character: 42,
- },
- };
- const requestEvent = new CustomEvent('create-comment-requested');
- let createRangeEvent;
- element.addEventListener('create-range-comment', e => {
- createRangeEvent = e;
- });
- element.dispatchEvent(requestEvent);
- assert.deepEqual(element.selectedRange, createRangeEvent.detail);
- assert.isTrue(element._removeActionBox.called);
- });
- });
-
- suite('selection', () => {
- let diff;
- let builder;
- let contentStubs;
-
- const stubContent = (line, side, opt_child) => {
- const contentTd = diff.querySelector(
- `.${side}.lineNum[data-value="${line}"] ~ .content`);
- const contentText = contentTd.querySelector('.contentText');
- const lineEl = diff.querySelector(
- `.${side}.lineNum[data-value="${line}"]`);
- contentStubs.push({
- lineEl,
- contentTd,
- contentText,
- });
- builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
- builder.getLineNumberByChild.withArgs(lineEl).returns(line);
- builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
- builder.getSideByLineEl.withArgs(lineEl).returns(side);
- return contentText;
- };
-
- const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
- const selection = document.getSelection();
- const range = document.createRange();
- range.setStart(startNode, startOffset);
- range.setEnd(endNode, endOffset);
- selection.addRange(range);
- element._handleSelection(selection);
- };
-
- const getLineElByChild = node => {
- const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
- return stubs && stubs.lineEl;
- };
-
- setup(() => {
- contentStubs = [];
- stub('gr-selection-action-box', 'placeAbove');
- stub('gr-selection-action-box', 'placeBelow');
- diff = element.querySelector('#diffTable');
- builder = {
- getContentTdByLine: sinon.stub(),
- getContentTdByLineEl: sinon.stub(),
- getLineElByChild,
- getLineNumberByChild: sinon.stub(),
- getSideByLineEl: sinon.stub(),
- };
- element._cachedDiffBuilder = builder;
- });
-
- teardown(() => {
- contentStubs = null;
- document.getSelection().removeAllRanges();
- });
-
- test('single first line', () => {
- const content = stubContent(1, 'right');
- sinon.spy(element, '_positionActionBox');
- emulateSelection(content.firstChild, 5, content.firstChild, 12);
- const actionBox = element.shadowRoot
- .querySelector('gr-selection-action-box');
- assert.isTrue(actionBox.positionBelow);
- });
-
- test('multiline starting on first line', () => {
- const startContent = stubContent(1, 'right');
- const endContent = stubContent(2, 'right');
- sinon.spy(element, '_positionActionBox');
- emulateSelection(
- startContent.firstChild, 10, endContent.lastChild, 7);
- const actionBox = element.shadowRoot
- .querySelector('gr-selection-action-box');
- assert.isTrue(actionBox.positionBelow);
- });
-
- test('single line', () => {
- const content = stubContent(138, 'left');
- sinon.spy(element, '_positionActionBox');
- emulateSelection(content.firstChild, 5, content.firstChild, 12);
- const actionBox = element.shadowRoot
- .querySelector('gr-selection-action-box');
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 138,
- start_character: 5,
- end_line: 138,
- end_character: 12,
- });
- assert.equal(side, 'left');
- assert.notOk(actionBox.positionBelow);
- });
-
- test('multiline', () => {
- const startContent = stubContent(119, 'right');
- const endContent = stubContent(120, 'right');
- sinon.spy(element, '_positionActionBox');
- emulateSelection(
- startContent.firstChild, 10, endContent.lastChild, 7);
- const actionBox = element.shadowRoot
- .querySelector('gr-selection-action-box');
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 119,
- start_character: 10,
- end_line: 120,
- end_character: 36,
- });
- assert.equal(side, 'right');
- assert.notOk(actionBox.positionBelow);
- });
-
- test('multiple ranges aka firefox implementation', () => {
- const startContent = stubContent(119, 'right');
- const endContent = stubContent(120, 'right');
-
- const startRange = document.createRange();
- startRange.setStart(startContent.firstChild, 10);
- startRange.setEnd(startContent.firstChild, 11);
-
- const endRange = document.createRange();
- endRange.setStart(endContent.lastChild, 6);
- endRange.setEnd(endContent.lastChild, 7);
-
- const getRangeAtStub = sinon.stub();
- getRangeAtStub
- .onFirstCall().returns(startRange)
- .onSecondCall()
- .returns(endRange);
- const selection = {
- rangeCount: 2,
- getRangeAt: getRangeAtStub,
- removeAllRanges: sinon.stub(),
- };
- element._handleSelection(selection);
- const {range} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 119,
- start_character: 10,
- end_line: 120,
- end_character: 36,
- });
- });
-
- test('multiline grow end highlight over tabs', () => {
- const startContent = stubContent(119, 'right');
- const endContent = stubContent(120, 'right');
- emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 119,
- start_character: 10,
- end_line: 120,
- end_character: 2,
- });
- assert.equal(side, 'right');
- });
-
- test('collapsed', () => {
- const content = stubContent(138, 'left');
- emulateSelection(content.firstChild, 5, content.firstChild, 5);
- assert.isOk(document.getSelection().getRangeAt(0).startContainer);
- assert.isFalse(!!element.selectedRange);
- });
-
- test('starts inside hl', () => {
- const content = stubContent(140, 'left');
- const hl = content.querySelector('.foo');
- emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 140,
- start_character: 8,
- end_line: 140,
- end_character: 23,
- });
- assert.equal(side, 'left');
- });
-
- test('ends inside hl', () => {
- const content = stubContent(140, 'left');
- const hl = content.querySelector('.bar');
- emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
- const {range} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 140,
- start_character: 18,
- end_line: 140,
- end_character: 27,
- });
- });
-
- test('multiple hl', () => {
- const content = stubContent(140, 'left');
- const hl = content.querySelectorAll('hl')[4];
- emulateSelection(content.firstChild, 2, hl.firstChild, 2);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 140,
- start_character: 2,
- end_line: 140,
- end_character: 61,
- });
- assert.equal(side, 'left');
- });
-
- test('starts outside of diff', () => {
- const contentText = stubContent(140, 'left');
- const contentTd = contentText.parentElement;
-
- emulateSelection(contentTd.parentElement, 0,
- contentText.firstChild, 2);
- assert.isFalse(!!element.selectedRange);
- });
-
- test('ends outside of diff', () => {
- const content = stubContent(140, 'left');
- emulateSelection(content.nextElementSibling.firstChild, 2,
- content.firstChild, 2);
- assert.isFalse(!!element.selectedRange);
- });
-
- test('starts and ends on different sides', () => {
- const startContent = stubContent(140, 'left');
- const endContent = stubContent(130, 'right');
- emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
- assert.isFalse(!!element.selectedRange);
- });
-
- test('starts in comment thread element', () => {
- const startContent = stubContent(140, 'left');
- const comment = startContent.parentElement.querySelector(
- '.comment-thread');
- const endContent = stubContent(141, 'left');
- emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 140,
- start_character: 83,
- end_line: 141,
- end_character: 4,
- });
- assert.equal(side, 'left');
- });
-
- test('ends in comment thread element', () => {
- const content = stubContent(140, 'left');
- const comment = content.parentElement.querySelector(
- '.comment-thread');
- emulateSelection(content.firstChild, 4, comment.firstChild, 1);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 140,
- start_character: 4,
- end_line: 140,
- end_character: 83,
- });
- assert.equal(side, 'left');
- });
-
- test('starts in context element', () => {
- const contextControl =
- diff.querySelector('.contextControl').querySelector('gr-button');
- const content = stubContent(146, 'right');
- emulateSelection(contextControl, 0, content.firstChild, 7);
- // TODO (viktard): Select nearest line.
- assert.isFalse(!!element.selectedRange);
- });
-
- test('ends in context element', () => {
- const contextControl =
- diff.querySelector('.contextControl').querySelector('gr-button');
- const content = stubContent(141, 'left');
- emulateSelection(content.firstChild, 2, contextControl, 1);
- // TODO (viktard): Select nearest line.
- assert.isFalse(!!element.selectedRange);
- });
-
- test('selection containing context element', () => {
- const startContent = stubContent(130, 'right');
- const endContent = stubContent(146, 'right');
- emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 130,
- start_character: 3,
- end_line: 146,
- end_character: 14,
- });
- assert.equal(side, 'right');
- });
-
- test('ends at a tab', () => {
- const content = stubContent(140, 'left');
- emulateSelection(
- content.firstChild, 1, content.querySelector('span'), 0);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 140,
- start_character: 1,
- end_line: 140,
- end_character: 51,
- });
- assert.equal(side, 'left');
- });
-
- test('starts at a tab', () => {
- const content = stubContent(140, 'left');
- emulateSelection(
- content.querySelectorAll('hl')[3], 0,
- content.querySelectorAll('span')[1].nextSibling, 1);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 140,
- start_character: 51,
- end_line: 140,
- end_character: 71,
- });
- assert.equal(side, 'left');
- });
-
- test('properly accounts for syntax highlighting', () => {
- const content = stubContent(140, 'left');
- const spy = sinon.spy(element, '_normalizeRange');
- emulateSelection(
- content.querySelectorAll('hl')[3], 0,
- content.querySelectorAll('span')[1], 0);
- const spyCall = spy.getCall(0);
- const range = document.getSelection().getRangeAt(0);
- assert.notDeepEqual(spyCall.returnValue, range);
- });
-
- test('GrRangeNormalizer._getTextOffset computes text offset', () => {
- let content = stubContent(140, 'left');
- let child = content.lastChild.lastChild;
- let result = _getTextOffset(content, child);
- assert.equal(result, 75);
- content = stubContent(146, 'right');
- child = content.lastChild;
- result = _getTextOffset(content, child);
- assert.equal(result, 0);
- });
-
- test('_fixTripleClickSelection', () => {
- const startContent = stubContent(119, 'right');
- const endContent = stubContent(120, 'right');
- emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
- const {range, side} = element.selectedRange;
- assert.deepEqual(range, {
- start_line: 119,
- start_character: 0,
- end_line: 119,
- end_character: element._getLength(startContent),
- });
- assert.equal(side, 'right');
- });
- });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..b819754
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,713 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-highlight';
+import {_getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html} from '@open-wc/testing-helpers';
+import {
+ GrDiffHighlight,
+ DiffBuilderInterface,
+ CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../gr-diff/gr-diff-utils';
+import {waitQueryAndAssert, waitUntil} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+ <table id="diffTable">
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="1"></td>
+ <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+ <td class="right lineNum" data-value="1"></td>
+ <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section delta">
+ <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+ <td class="left lineNum" data-value="2"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+ <td class="right lineNum" data-value="2"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="138"></td>
+ <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+ <td class="right lineNum" data-value="119"></td>
+ <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section delta">
+ <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+ <td class="left lineNum" data-value="140"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+ [Yet another random diff thread content here]
+ </div></td>
+ <td class="right lineNum" data-value="120"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum, <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam, sit, quod</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="141"></td>
+ <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+ <td class="right lineNum" data-value="130"></td>
+ <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section contextControl">
+ <tr
+ class="diff-row side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="left contextLineNum"></td>
+ <td>
+ <gr-button>+10↑</gr-button>
+ -
+ <gr-button>Show 21 common lines</gr-button>
+ -
+ <gr-button>+10↓</gr-button>
+ </td>
+ <td class="right contextLineNum"></td>
+ <td>
+ <gr-button>+10↑</gr-button>
+ -
+ <gr-button>Show 21 common lines</gr-button>
+ -
+ <gr-button>+10↓</gr-button>
+ </td>
+ </tr>
+ </tbody>
+
+ <tbody class="section delta total">
+ <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+ <td class="left"></td>
+ <td class="blank"></td>
+ <td class="right lineNum" data-value="146"></td>
+ <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="165"></td>
+ <td class="content both"><div class="contentText"></div></td>
+ <td class="right lineNum" data-value="147"></td>
+ <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+ </tr>
+ </tbody>
+ </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+ suite('comment events', () => {
+ let threadEl: GrDiffThreadElement;
+ let hlRange: HTMLElement;
+ let element: GrDiffHighlight;
+ let diff: HTMLElement;
+ let builder: {
+ getContentTdByLineEl: SinonStubbedMember<
+ DiffBuilderInterface['getContentTdByLineEl']
+ >;
+ };
+
+ setup(async () => {
+ diff = await fixture<HTMLTableElement>(diffTable);
+ builder = {
+ getContentTdByLineEl: sinon.stub(),
+ };
+ element = new GrDiffHighlight();
+ element.init(diff, builder);
+ hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+ threadEl = document.createElement(
+ 'div'
+ ) as unknown as GrDiffThreadElement;
+ threadEl.className = 'comment-thread';
+ threadEl.rootId = 'id314';
+ diff.appendChild(threadEl);
+ });
+
+ teardown(() => {
+ element.cleanup();
+ threadEl.remove();
+ });
+
+ test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+ assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+ threadEl.dispatchEvent(
+ new CustomEvent('comment-thread-mouseenter', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+ assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+ });
+
+ test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+ hlRange.classList.add('rangeHoverHighlight');
+ threadEl.dispatchEvent(
+ new CustomEvent('comment-thread-mouseleave', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+ assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+ });
+
+ test(`create-range-comment for range when create-comment-requested
+ is fired`, () => {
+ const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+ element.selectedRange = {
+ side: Side.LEFT,
+ range: {
+ start_line: 7,
+ start_character: 11,
+ end_line: 24,
+ end_character: 42,
+ },
+ };
+ const requestEvent = new CustomEvent('create-comment-requested');
+ let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+ diff.addEventListener('create-range-comment', e => {
+ createRangeEvent = e;
+ });
+ diff.dispatchEvent(requestEvent);
+ if (!createRangeEvent!) assert.fail('event not set');
+ assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+ assert.isTrue(removeActionBoxStub.called);
+ });
+ });
+
+ suite('selection', () => {
+ let element: GrDiffHighlight;
+ let diff: HTMLElement;
+ let builder: {
+ getContentTdByLineEl: SinonStubbedMember<
+ DiffBuilderInterface['getContentTdByLineEl']
+ >;
+ };
+ let contentStubs;
+
+ setup(async () => {
+ diff = await fixture<HTMLTableElement>(diffTable);
+ builder = {
+ getContentTdByLineEl: sinon.stub(),
+ };
+ element = new GrDiffHighlight();
+ element.init(diff, builder);
+ contentStubs = [];
+ stub('gr-selection-action-box', 'placeAbove');
+ stub('gr-selection-action-box', 'placeBelow');
+ });
+
+ teardown(() => {
+ fixtureCleanup();
+ element.cleanup();
+ contentStubs = null;
+ document.getSelection()!.removeAllRanges();
+ });
+
+ const stubContent = (line: number, side: Side) => {
+ const contentTd = diff.querySelector(
+ `.${side}.lineNum[data-value="${line}"] ~ .content`
+ );
+ if (!contentTd) assert.fail('content td not found');
+ const contentText = contentTd.querySelector('.contentText');
+ const lineEl =
+ diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+ undefined;
+ contentStubs.push({
+ lineEl,
+ contentTd,
+ contentText,
+ });
+ builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+ return contentText;
+ };
+
+ const emulateSelection = (
+ startNode: Node,
+ startOffset: number,
+ endNode: Node,
+ endOffset: number
+ ) => {
+ const selection = document.getSelection();
+ if (!selection) assert.fail('no selection');
+ selection.removeAllRanges();
+ const range = document.createRange();
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+ selection.addRange(range);
+ element.handleSelection(selection, false);
+ };
+
+ test('single first line', () => {
+ const content = stubContent(1, Side.RIGHT);
+ sinon.spy(element, 'positionActionBox');
+ if (!content?.firstChild) assert.fail('content first child not found');
+ emulateSelection(content.firstChild, 5, content.firstChild, 12);
+ const actionBox = diff.querySelector('gr-selection-action-box');
+ if (!actionBox) assert.fail('action box not found');
+ assert.isTrue(actionBox.positionBelow);
+ });
+
+ test('multiline starting on first line', () => {
+ const startContent = stubContent(1, Side.RIGHT);
+ const endContent = stubContent(2, Side.RIGHT);
+ sinon.spy(element, 'positionActionBox');
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.lastChild) {
+ assert.fail('last child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+ const actionBox = diff.querySelector('gr-selection-action-box');
+ if (!actionBox) assert.fail('action box not found');
+ assert.isTrue(actionBox.positionBelow);
+ });
+
+ test('single line', async () => {
+ const content = stubContent(138, Side.LEFT);
+ sinon.spy(element, 'positionActionBox');
+ if (!content?.firstChild) assert.fail('content first child not found');
+ emulateSelection(content.firstChild, 5, content.firstChild, 12);
+ const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+ diff,
+ 'gr-selection-action-box'
+ );
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 138,
+ start_character: 5,
+ end_line: 138,
+ end_character: 12,
+ });
+ assert.equal(side, Side.LEFT);
+ assert.notOk(actionBox.positionBelow);
+ });
+
+ test('multiline', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ sinon.spy(element, 'positionActionBox');
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.lastChild) {
+ assert.fail('last child of end content');
+ }
+ emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+ const actionBox = diff.querySelector('gr-selection-action-box');
+ if (!actionBox) assert.fail('action box not found');
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 10,
+ end_line: 120,
+ end_character: 36,
+ });
+ assert.equal(side, Side.RIGHT);
+ assert.notOk(actionBox.positionBelow);
+ });
+
+ test('multiple ranges aka firefox implementation', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.lastChild) {
+ assert.fail('last child of end content');
+ }
+
+ const startRange = document.createRange();
+ startRange.setStart(startContent.firstChild, 10);
+ startRange.setEnd(startContent.firstChild, 11);
+
+ const endRange = document.createRange();
+ endRange.setStart(endContent.lastChild, 6);
+ endRange.setEnd(endContent.lastChild, 7);
+
+ const getRangeAtStub = sinon.stub();
+ getRangeAtStub
+ .onFirstCall()
+ .returns(startRange)
+ .onSecondCall()
+ .returns(endRange);
+ const selection = {
+ rangeCount: 2,
+ getRangeAt: getRangeAtStub,
+ removeAllRanges: sinon.stub(),
+ } as unknown as Selection;
+ element.handleSelection(selection, false);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 10,
+ end_line: 120,
+ end_character: 36,
+ });
+ });
+
+ test('multiline grow end highlight over tabs', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 10,
+ end_line: 120,
+ end_character: 2,
+ });
+ assert.equal(side, Side.RIGHT);
+ });
+
+ test('collapsed', () => {
+ const content = stubContent(138, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content not found');
+ }
+ emulateSelection(content.firstChild, 5, content.firstChild, 5);
+ const sel = document.getSelection();
+ if (!sel) assert.fail('no selection');
+ assert.isOk(sel.getRangeAt(0).startContainer);
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('starts inside hl', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) {
+ assert.fail('content not found');
+ }
+ const hl = content.querySelector('.foo');
+ if (!hl?.firstChild) {
+ assert.fail('first child of hl element not found');
+ }
+ if (!hl?.nextSibling) {
+ assert.fail('next sibling of hl element not found');
+ }
+ emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 8,
+ end_line: 140,
+ end_character: 23,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('ends inside hl', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content not found');
+ const hl = content.querySelector('.bar');
+ if (!hl) assert.fail('hl inside content not found');
+ if (!hl.previousSibling) assert.fail('previous sibling not found');
+ if (!hl.firstChild) assert.fail('first child not found');
+ emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 18,
+ end_line: 140,
+ end_character: 27,
+ });
+ });
+
+ test('multiple hl', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content not found');
+ if (!content.firstChild) assert.fail('first child not found');
+ const hl = content.querySelectorAll('hl')[4];
+ if (!hl) assert.fail('hl not found');
+ if (!hl.firstChild) assert.fail('first child of hl not found');
+ emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 2,
+ end_line: 140,
+ end_character: 61,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('starts outside of diff', () => {
+ const contentText = stubContent(140, Side.LEFT);
+ if (!contentText) assert.fail('content not found');
+ if (!contentText.firstChild) assert.fail('child not found');
+ const contentTd = contentText.parentElement;
+ if (!contentTd) assert.fail('content td not found');
+ if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+ emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('ends outside of diff', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content not found');
+ if (!content.firstChild) assert.fail('child not found');
+ if (!content.nextElementSibling) assert.fail('sibling not found');
+ if (!content.nextElementSibling.firstChild) {
+ assert.fail('sibling child not found');
+ }
+ emulateSelection(
+ content.nextElementSibling.firstChild,
+ 2,
+ content.firstChild,
+ 2
+ );
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('starts and ends on different sides', () => {
+ const startContent = stubContent(140, Side.LEFT);
+ const endContent = stubContent(130, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('starts in comment thread element', () => {
+ const startContent = stubContent(140, Side.LEFT);
+ if (!startContent?.parentElement) {
+ assert.fail('parent el of start content not found');
+ }
+ const comment =
+ startContent.parentElement.querySelector('.comment-thread');
+ if (!comment?.firstChild) {
+ assert.fail('first child of comment not found');
+ }
+ const endContent = stubContent(141, Side.LEFT);
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 83,
+ end_line: 141,
+ end_character: 4,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('ends in comment thread element', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content not found');
+ }
+ if (!content?.parentElement) {
+ assert.fail('parent element of content not found');
+ }
+ const comment = content.parentElement.querySelector('.comment-thread');
+ if (!comment?.firstChild) {
+ assert.fail('first child of comment element not found');
+ }
+ emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 4,
+ end_line: 140,
+ end_character: 83,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('starts in context element', () => {
+ const contextControl = diff
+ .querySelector('.contextControl')!
+ .querySelector('gr-button');
+ if (!contextControl) assert.fail('context control not found');
+ const content = stubContent(146, Side.RIGHT);
+ if (!content) assert.fail('content not found');
+ if (!content.firstChild) assert.fail('content child not found');
+ emulateSelection(contextControl, 0, content.firstChild, 7);
+ // TODO (viktard): Select nearest line.
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('ends in context element', () => {
+ const contextControl = diff
+ .querySelector('.contextControl')!
+ .querySelector('gr-button');
+ if (!contextControl) {
+ assert.fail('context control element not found');
+ }
+ const content = stubContent(141, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content element not found');
+ }
+ emulateSelection(content.firstChild, 2, contextControl, 1);
+ // TODO (viktard): Select nearest line.
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('selection containing context element', () => {
+ const startContent = stubContent(130, Side.RIGHT);
+ const endContent = stubContent(146, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 130,
+ start_character: 3,
+ end_line: 146,
+ end_character: 14,
+ });
+ assert.equal(side, Side.RIGHT);
+ });
+
+ test('ends at a tab', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content element not found');
+ }
+ const span = content.querySelector('span');
+ if (!span) assert.fail('span element not found');
+ emulateSelection(content.firstChild, 1, span, 0);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 1,
+ end_line: 140,
+ end_character: 51,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('starts at a tab', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content element not found');
+ emulateSelection(
+ content.querySelectorAll('hl')[3],
+ 0,
+ content.querySelectorAll('span')[1].nextSibling!,
+ 1
+ );
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 51,
+ end_line: 140,
+ end_character: 71,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('properly accounts for syntax highlighting', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content element not found');
+ emulateSelection(
+ content.querySelectorAll('hl')[3],
+ 0,
+ content.querySelectorAll('span')[1],
+ 0
+ );
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 51,
+ end_line: 140,
+ end_character: 69,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+ let content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content element not found');
+ if (!content.lastChild) assert.fail('last child of content not found');
+ let child = content.lastChild.lastChild;
+ if (!child) assert.fail('last child of last child of content not found');
+ let result = _getTextOffset(content, child);
+ assert.equal(result, 75);
+ content = stubContent(146, Side.RIGHT);
+ if (!content) assert.fail('content element not found');
+ child = content.lastChild;
+ if (!child) assert.fail('child element not found');
+ result = _getTextOffset(content, child);
+ assert.equal(result, 0);
+ });
+
+ test('fixTripleClickSelection', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent) assert.fail('end content not found');
+ if (!endContent.firstChild) assert.fail('first child not found');
+ emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 0,
+ end_line: 119,
+ end_character: element.getLength(startContent),
+ });
+ assert.equal(side, Side.RIGHT);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index 2665ef0..4bb8cc3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -1,39 +1,23 @@
/**
* @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/shared-styles';
-import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-selection_html';
import {
normalize,
NormalizedRange,
} from '../gr-diff-highlight/gr-range-normalizer';
import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
import {DiffInfo} from '../../../types/diff';
import {Side} from '../../../constants/constants';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
import {
getLineElByChild,
getSide,
getSideByLineEl,
isThreadEl,
} from '../gr-diff/gr-diff-utils';
+import {assertIsDefined} from '../../../utils/common-util';
/**
* Possible CSS classes indicating the state of selection. Dynamically added/
@@ -55,49 +39,35 @@
return {left: null, right: null};
}
-@customElement('gr-diff-selection')
-export class GrDiffSelection extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
-
- @property({type: Object})
+export class GrDiffSelection {
+ // visible for testing
diff?: DiffInfo;
- @property({type: Object})
- _cachedDiffBuilder?: GrDiffBuilderElement;
+ // visible for testing
+ diffTable?: HTMLElement;
- @property({type: Object})
- _linesCache: LinesCache = {left: null, right: null};
+ // visible for testing
+ linesCache: LinesCache = getNewCache();
- constructor() {
- super();
- this.addEventListener('copy', e => this._handleCopy(e));
- addListener(this, 'down', e => this._handleDown(e));
+ init(diff: DiffInfo, diffTable: HTMLElement) {
+ this.cleanup();
+ this.diff = diff;
+ this.diffTable = diffTable;
+ this.diffTable.classList.add(SelectionClass.RIGHT);
+ this.diffTable.addEventListener('copy', this.handleCopy);
+ this.diffTable.addEventListener('mousedown', this.handleDown);
+ this.linesCache = getNewCache();
}
- override connectedCallback() {
- super.connectedCallback();
- this.classList.add(SelectionClass.RIGHT);
+ cleanup() {
+ if (!this.diffTable) return;
+ this.diffTable.removeEventListener('copy', this.handleCopy);
+ this.diffTable.removeEventListener('mousedown', this.handleDown);
}
- get diffBuilder() {
- if (!this._cachedDiffBuilder) {
- this._cachedDiffBuilder = this.querySelector(
- 'gr-diff-builder'
- ) as GrDiffBuilderElement;
- }
- return this._cachedDiffBuilder;
- }
-
- @observe('diff')
- _diffChanged() {
- this._linesCache = getNewCache();
- }
-
- _handleDownOnRangeComment(node: Element) {
+ handleDownOnRangeComment(node: Element) {
if (isThreadEl(node)) {
- this._setClasses([
+ this.setClasses([
SelectionClass.COMMENT,
getSide(node) === Side.LEFT
? SelectionClass.LEFT
@@ -108,14 +78,13 @@
return false;
}
- _handleDown(e: Event) {
+ handleDown = (e: Event) => {
const target = e.target;
if (!(target instanceof Element)) return;
- // Handle the down event on comment thread in Polymer 2
- const handled = this._handleDownOnRangeComment(target);
+ const handled = this.handleDownOnRangeComment(target);
if (handled) return;
const lineEl = getLineElByChild(target);
- const blameSelected = this._elementDescendedFromClass(target, 'blame');
+ const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
if (!lineEl && !blameSelected) {
return;
}
@@ -125,9 +94,10 @@
if (blameSelected) {
targetClasses.push(SelectionClass.BLAME);
} else if (lineEl) {
- const commentSelected = this._elementDescendedFromClass(
+ const commentSelected = descendedFromClass(
target,
- 'gr-comment'
+ 'gr-comment',
+ this.diffTable
);
const side = getSideByLineEl(lineEl);
@@ -140,60 +110,50 @@
}
}
- this._setClasses(targetClasses);
- }
+ this.setClasses(targetClasses);
+ };
/**
* Set the provided list of classes on the element, to the exclusion of all
* other SelectionClass values.
*/
- _setClasses(targetClasses: string[]) {
+ setClasses(targetClasses: string[]) {
+ if (!this.diffTable) return;
// Remove any selection classes that do not belong.
for (const className of Object.values(SelectionClass)) {
if (!targetClasses.includes(className)) {
- this.classList.remove(className);
+ this.diffTable.classList.remove(className);
}
}
// Add new selection classes iff they are not already present.
- for (const _class of targetClasses) {
- if (!this.classList.contains(_class)) {
- this.classList.add(_class);
+ for (const targetClass of targetClasses) {
+ if (!this.diffTable.classList.contains(targetClass)) {
+ this.diffTable.classList.add(targetClass);
}
}
}
- _getCopyEventTarget(e: Event) {
- return (dom(e) as EventApi).rootTarget;
- }
-
- /**
- * Utility function to determine whether an element is a descendant of
- * another element with the particular className.
- */
- _elementDescendedFromClass(element: Element, className: string) {
- return descendedFromClass(element, className, this.diffBuilder.diffElement);
- }
-
- _handleCopy(e: ClipboardEvent) {
+ handleCopy = (e: ClipboardEvent) => {
let commentSelected = false;
- const target = this._getCopyEventTarget(e);
+ const target = e.composedPath()[0];
if (!(target instanceof Element)) return;
if (target instanceof HTMLTextAreaElement) return;
- if (!this._elementDescendedFromClass(target, 'diff-row')) return;
- if (this.classList.contains(SelectionClass.COMMENT)) {
+ if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+ if (!this.diffTable) return;
+ if (this.diffTable.classList.contains(SelectionClass.COMMENT)) {
commentSelected = true;
}
const lineEl = getLineElByChild(target);
if (!lineEl) return;
const side = getSideByLineEl(lineEl);
- const text = this._getSelectedText(side, commentSelected);
+ const text = this.getSelectedText(side, commentSelected);
if (text && e.clipboardData) {
e.clipboardData.setData('Text', text);
e.preventDefault();
}
- }
+ };
- _getSelection() {
+ getSelection() {
const diffHosts = querySelectorAll(document.body, 'gr-diff');
if (!diffHosts.length) return document.getSelection();
@@ -219,13 +179,13 @@
* @param commentSelected Whether or not a comment is selected.
* @return The selected text.
*/
- _getSelectedText(side: Side, commentSelected: boolean) {
- const sel = this._getSelection();
+ getSelectedText(side: Side, commentSelected: boolean) {
+ const sel = this.getSelection();
if (!sel || sel.rangeCount !== 1) {
return ''; // No multi-select support yet.
}
if (commentSelected) {
- return this._getCommentLines(sel, side);
+ return this.getCommentLines(sel, side);
}
const range = normalize(sel.getRangeAt(0));
const startLineEl = getLineElByChild(range.startContainer);
@@ -250,7 +210,7 @@
if (endLineDataValue) endLineNum = Number(endLineDataValue);
}
- return this._getRangeFromDiff(
+ return this.getRangeFromDiff(
startLineNum,
range.startOffset,
endLineNum,
@@ -262,7 +222,7 @@
/**
* Query the diff object for the selected lines.
*/
- _getRangeFromDiff(
+ getRangeFromDiff(
startLineNum: number,
startOffset: number,
endLineNum: number | undefined,
@@ -274,7 +234,7 @@
startLineNum -= skipChunk.skip!;
if (endLineNum) endLineNum -= skipChunk.skip!;
}
- const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+ const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
if (lines.length) {
lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
lines[0] = lines[0].substring(startOffset);
@@ -288,9 +248,9 @@
* @param side The side that is currently selected.
* @return An array of strings indexed by line number.
*/
- _getDiffLines(side: Side): string[] {
- if (this._linesCache[side]) {
- return this._linesCache[side]!;
+ getDiffLines(side: Side): string[] {
+ if (this.linesCache[side]) {
+ return this.linesCache[side]!;
}
if (!this.diff) return [];
let lines: string[] = [];
@@ -303,7 +263,7 @@
lines = lines.concat(chunk.b);
}
}
- this._linesCache[side] = lines;
+ this.linesCache[side] = lines;
return lines;
}
@@ -315,11 +275,11 @@
* @param side The side that is currently selected.
* @return The selected comment text.
*/
- _getCommentLines(sel: Selection, side: Side) {
+ getCommentLines(sel: Selection, side: Side) {
const range = normalize(sel.getRangeAt(0));
const content = [];
- // Query the diffElement for comments.
- const messages = this.diffBuilder.diffElement.querySelectorAll(
+ assertIsDefined(this.diffTable, 'diffTable');
+ const messages = this.diffTable.querySelectorAll(
`.side-by-side [data-side="${side}"] .message *, .unified .message *`
);
@@ -339,9 +299,9 @@
if (
el.id === 'output' &&
- !this._elementDescendedFromClass(el, 'collapsed')
+ !descendedFromClass(el, 'collapsed', this.diffTable)
) {
- content.push(this._getTextContentForRange(el, sel, range));
+ content.push(this.getTextContentForRange(el, sel, range));
}
}
}
@@ -359,7 +319,7 @@
* @param range The normalized selection range.
* @return The text within the selection.
*/
- _getTextContentForRange(
+ getTextContentForRange(
domNode: Node,
sel: Selection,
range: NormalizedRange
@@ -379,15 +339,9 @@
}
} else {
for (const childNode of domNode.childNodes) {
- text += this._getTextContentForRange(childNode, sel, range);
+ text += this.getTextContentForRange(childNode, sel, range);
}
}
return text;
}
}
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-diff-selection': GrDiffSelection;
- }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
+++ /dev/null
@@ -1,23 +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`
- <div class="contentWrapper">
- <slot></slot>
- </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index dc7f6a2..b44114a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -5,121 +5,115 @@
*/
import '../../../test/common-test-setup-karma';
import './gr-diff-selection';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
import {GrDiffSelection} from './gr-diff-selection';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
import {createDiff} from '../../../test/test-data-generators';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {Side} from '../../../api/diff';
+import {DiffInfo, Side} from '../../../api/diff';
import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mouseDown} from '../../../test/test-utils';
-const basicFixture = fixtureFromTemplate(html`
- <gr-diff-selection>
- <table id="diffTable" class="side-by-side">
- <tr class="diff-row">
- <td class="blame" data-line-number="1"></td>
- <td class="lineNum left" data-value="1">1</td>
- <td class="content">
- <div class="contentText" data-side="left">ba ba</div>
- <div data-side="left">
- <div class="comment-thread">
- <div class="gr-formatted-text message">
- <span id="output" class="gr-linked-text"
- >This is a comment</span
- >
- </div>
+const diffTableTemplate = html`
+ <table id="diffTable" class="side-by-side">
+ <tr class="diff-row">
+ <td class="blame" data-line-number="1"></td>
+ <td class="lineNum left" data-value="1">1</td>
+ <td class="content">
+ <div class="contentText" data-side="left">ba ba</div>
+ <div data-side="left">
+ <div class="comment-thread">
+ <div class="gr-formatted-text message">
+ <span id="output" class="gr-linked-text">This is a comment</span>
</div>
</div>
- </td>
- <td class="lineNum right" data-value="1">1</td>
- <td class="content">
- <div class="contentText" data-side="right">some other text</div>
- </td>
- </tr>
- <tr class="diff-row">
- <td class="blame" data-line-number="2"></td>
- <td class="lineNum left" data-value="2">2</td>
- <td class="content">
- <div class="contentText" data-side="left">zin</div>
- </td>
- <td class="lineNum right" data-value="2">2</td>
- <td class="content">
- <div class="contentText" data-side="right">more more more</div>
- <div data-side="right">
- <div class="comment-thread">
- <div class="gr-formatted-text message">
- <span id="output" class="gr-linked-text"
- >This is a comment on the right</span
- >
- </div>
+ </div>
+ </td>
+ <td class="lineNum right" data-value="1">1</td>
+ <td class="content">
+ <div class="contentText" data-side="right">some other text</div>
+ </td>
+ </tr>
+ <tr class="diff-row">
+ <td class="blame" data-line-number="2"></td>
+ <td class="lineNum left" data-value="2">2</td>
+ <td class="content">
+ <div class="contentText" data-side="left">zin</div>
+ </td>
+ <td class="lineNum right" data-value="2">2</td>
+ <td class="content">
+ <div class="contentText" data-side="right">more more more</div>
+ <div data-side="right">
+ <div class="comment-thread">
+ <div class="gr-formatted-text message">
+ <span id="output" class="gr-linked-text"
+ >This is a comment on the right</span
+ >
</div>
</div>
- </td>
- </tr>
- <tr class="diff-row">
- <td class="blame" data-line-number="3"></td>
- <td class="lineNum left" data-value="3">3</td>
- <td class="content">
- <div class="contentText" data-side="left">ga ga</div>
- <div data-side="left">
- <div class="comment-thread">
- <div class="gr-formatted-text message">
- <span id="output" class="gr-linked-text"
- >This is <a>a</a> different comment 💩 unicode is fun</span
- >
- </div>
+ </div>
+ </td>
+ </tr>
+ <tr class="diff-row">
+ <td class="blame" data-line-number="3"></td>
+ <td class="lineNum left" data-value="3">3</td>
+ <td class="content">
+ <div class="contentText" data-side="left">ga ga</div>
+ <div data-side="left">
+ <div class="comment-thread">
+ <div class="gr-formatted-text message">
+ <span id="output" class="gr-linked-text"
+ >This is <a>a</a> different comment 💩 unicode is fun</span
+ >
</div>
</div>
- </td>
- <td class="lineNum right" data-value="3">3</td>
- </tr>
- <tr class="diff-row">
- <td class="blame" data-line-number="4"></td>
- <td class="lineNum left" data-value="4">4</td>
- <td class="content">
- <div class="contentText" data-side="left">ga ga</div>
- <div data-side="left">
- <div class="comment-thread">
- <textarea data-side="right">test for textarea copying</textarea>
- </div>
+ </div>
+ </td>
+ <td class="lineNum right" data-value="3">3</td>
+ </tr>
+ <tr class="diff-row">
+ <td class="blame" data-line-number="4"></td>
+ <td class="lineNum left" data-value="4">4</td>
+ <td class="content">
+ <div class="contentText" data-side="left">ga ga</div>
+ <div data-side="left">
+ <div class="comment-thread">
+ <textarea data-side="right">test for textarea copying</textarea>
</div>
- </td>
- <td class="lineNum right" data-value="4">4</td>
- </tr>
- <tr class="not-diff-row">
- <td class="other">
- <div class="contentText" data-side="right">some other text</div>
- </td>
- </tr>
- </table>
- </gr-diff-selection>
-`);
+ </div>
+ </td>
+ <td class="lineNum right" data-value="4">4</td>
+ </tr>
+ <tr class="not-diff-row">
+ <td class="other">
+ <div class="contentText" data-side="right">some other text</div>
+ </td>
+ </tr>
+ </table>
+`;
suite('gr-diff-selection', () => {
let element: GrDiffSelection;
- let getCopyEventTargetStub: sinon.SinonStub;
+ let diffTable: HTMLTableElement;
const emulateCopyOn = function (target: HTMLElement | null) {
const fakeEvent = {
target,
preventDefault: sinon.stub(),
+ composedPath() {
+ return [target];
+ },
clipboardData: {
setData: sinon.stub(),
},
};
- getCopyEventTargetStub.returns(target);
- element._handleCopy(fakeEvent as unknown as ClipboardEvent);
+ element.handleCopy(fakeEvent as unknown as ClipboardEvent);
return fakeEvent;
};
- setup(() => {
- element = basicFixture.instantiate() as GrDiffSelection;
+ setup(async () => {
+ element = new GrDiffSelection();
+ diffTable = await fixture<HTMLTableElement>(diffTableTemplate);
- getCopyEventTargetStub = sinon.stub(element, '_getCopyEventTarget');
- element._cachedDiffBuilder = {
- diffElement: element.querySelector('#diffTable')!,
- } as GrDiffBuilderElement;
- element.diff = {
+ const diff: DiffInfo = {
...createDiff(),
content: [
{
@@ -136,228 +130,231 @@
},
],
};
+ element.init(diff, diffTable);
});
test('applies selected-left on left side click', () => {
- element.classList.add('selected-right');
- const lineNumberEl = element.querySelector('.lineNum.left');
- assert.isOk(lineNumberEl);
- MockInteractions.down(lineNumberEl!);
+ element.diffTable!.classList.add('selected-right');
+ const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+ if (!lineNumberEl) assert.fail('line number element missing');
+ mouseDown(lineNumberEl);
assert.isTrue(
- element.classList.contains('selected-left'),
+ element.diffTable!.classList.contains('selected-left'),
'adds selected-left'
);
assert.isFalse(
- element.classList.contains('selected-right'),
+ element.diffTable!.classList.contains('selected-right'),
'removes selected-right'
);
});
test('applies selected-right on right side click', () => {
- element.classList.add('selected-left');
- const lineNumberEl = element.querySelector('.lineNum.right');
- assert.isOk(lineNumberEl);
- MockInteractions.down(lineNumberEl!);
+ element.diffTable!.classList.add('selected-left');
+ const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+ if (!lineNumberEl) assert.fail('line number element missing');
+ mouseDown(lineNumberEl);
assert.isTrue(
- element.classList.contains('selected-right'),
+ element.diffTable!.classList.contains('selected-right'),
'adds selected-right'
);
assert.isFalse(
- element.classList.contains('selected-left'),
+ element.diffTable!.classList.contains('selected-left'),
'removes selected-left'
);
});
test('applies selected-blame on blame click', () => {
- element.classList.add('selected-left');
- sinon
- .stub(element, '_elementDescendedFromClass')
- .callsFake((_: Element, className: string) => className === 'blame');
- MockInteractions.down(element);
+ element.diffTable!.classList.add('selected-left');
+ const blameDiv = document.createElement('div');
+ blameDiv.classList.add('blame');
+ element.diffTable!.appendChild(blameDiv);
+ mouseDown(blameDiv);
assert.isTrue(
- element.classList.contains('selected-blame'),
+ element.diffTable!.classList.contains('selected-blame'),
'adds selected-right'
);
assert.isFalse(
- element.classList.contains('selected-left'),
+ element.diffTable!.classList.contains('selected-left'),
'removes selected-left'
);
});
test('ignores copy for non-content Element', () => {
- const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
- emulateCopyOn(element.querySelector('.not-diff-row'));
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('.not-diff-row'));
assert.isFalse(getSelectedTextStub.called);
});
test('asks for text for left side Elements', () => {
- const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
- emulateCopyOn(element.querySelector('div.contentText'));
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('div.contentText'));
assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
});
test('reacts to copy for content Elements', () => {
- const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
- emulateCopyOn(element.querySelector('div.contentText'));
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('div.contentText'));
assert.isTrue(getSelectedTextStub.called);
});
test('copy event is prevented for content Elements', () => {
- const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
getSelectedTextStub.returns('test');
- const event = emulateCopyOn(element.querySelector('div.contentText'));
+ const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
assert.isTrue(event.preventDefault.called);
});
test('inserts text into clipboard on copy', () => {
- sinon.stub(element, '_getSelectedText').returns('the text');
- const event = emulateCopyOn(element.querySelector('div.contentText'));
+ sinon.stub(element, 'getSelectedText').returns('the text');
+ const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
assert.deepEqual(
['Text', 'the text'],
event.clipboardData.setData.lastCall.args
);
});
- test('_setClasses adds given SelectionClass values, removes others', () => {
- element.classList.add('selected-right');
- element._setClasses(['selected-comment', 'selected-left']);
- assert.isTrue(element.classList.contains('selected-comment'));
- assert.isTrue(element.classList.contains('selected-left'));
- assert.isFalse(element.classList.contains('selected-right'));
- assert.isFalse(element.classList.contains('selected-blame'));
+ test('setClasses adds given SelectionClass values, removes others', () => {
+ element.diffTable!.classList.add('selected-right');
+ element.setClasses(['selected-comment', 'selected-left']);
+ assert.isTrue(element.diffTable!.classList.contains('selected-comment'));
+ assert.isTrue(element.diffTable!.classList.contains('selected-left'));
+ assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+ assert.isFalse(element.diffTable!.classList.contains('selected-blame'));
- element._setClasses(['selected-blame']);
- assert.isFalse(element.classList.contains('selected-comment'));
- assert.isFalse(element.classList.contains('selected-left'));
- assert.isFalse(element.classList.contains('selected-right'));
- assert.isTrue(element.classList.contains('selected-blame'));
+ element.setClasses(['selected-blame']);
+ assert.isFalse(element.diffTable!.classList.contains('selected-comment'));
+ assert.isFalse(element.diffTable!.classList.contains('selected-left'));
+ assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+ assert.isTrue(element.diffTable!.classList.contains('selected-blame'));
});
- test('_setClasses removes before it ads', () => {
- element.classList.add('selected-right');
- const addStub = sinon.stub(element.classList, 'add');
- const removeStub = sinon.stub(element.classList, 'remove').callsFake(() => {
- assert.isFalse(addStub.called);
- });
- element._setClasses(['selected-comment', 'selected-left']);
+ test('setClasses removes before it ads', () => {
+ element.diffTable!.classList.add('selected-right');
+ const addStub = sinon.stub(element.diffTable!.classList, 'add');
+ const removeStub = sinon
+ .stub(element.diffTable!.classList, 'remove')
+ .callsFake(() => {
+ assert.isFalse(addStub.called);
+ });
+ element.setClasses(['selected-comment', 'selected-left']);
assert.isTrue(addStub.called);
assert.isTrue(removeStub.called);
});
test('copies content correctly', () => {
- element.classList.add('selected-left');
- element.classList.remove('selected-right');
+ element.diffTable!.classList.add('selected-left');
+ element.diffTable!.classList.remove('selected-right');
const selection = document.getSelection();
if (selection === null) assert.fail('no selection');
selection.removeAllRanges();
const range = document.createRange();
- range.setStart(element.querySelector('div.contentText')!.firstChild!, 3);
+ range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
range.setEnd(
- element.querySelectorAll('div.contentText')[4]!.firstChild!,
+ diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
2
);
selection.addRange(range);
- assert.equal(element._getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+ assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
});
test('copies comments', () => {
- element.classList.add('selected-left');
- element.classList.add('selected-comment');
- element.classList.remove('selected-right');
+ element.diffTable!.classList.add('selected-left');
+ element.diffTable!.classList.add('selected-comment');
+ element.diffTable!.classList.remove('selected-right');
const selection = document.getSelection();
if (selection === null) assert.fail('no selection');
selection.removeAllRanges();
const range = document.createRange();
range.setStart(
- element.querySelector('.gr-formatted-text *')!.firstChild!,
+ diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
3
);
range.setEnd(
- element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
+ diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
7
);
selection.addRange(range);
assert.equal(
's is a comment\nThis is a differ',
- element._getSelectedText(Side.LEFT, true)
+ element.getSelectedText(Side.LEFT, true)
);
});
test('respects astral chars in comments', () => {
- element.classList.add('selected-left');
- element.classList.add('selected-comment');
- element.classList.remove('selected-right');
+ element.diffTable!.classList.add('selected-left');
+ element.diffTable!.classList.add('selected-comment');
+ element.diffTable!.classList.remove('selected-right');
const selection = document.getSelection();
if (selection === null) assert.fail('no selection');
selection.removeAllRanges();
const range = document.createRange();
- const nodes = element.querySelectorAll('.gr-formatted-text *');
+ const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
range.setStart(nodes[2].childNodes[2], 13);
range.setEnd(nodes[2].childNodes[2], 23);
selection.addRange(range);
- assert.equal('mment 💩 u', element._getSelectedText(Side.LEFT, true));
+ assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
});
test('defers to default behavior for textarea', () => {
- element.classList.add('selected-left');
- element.classList.remove('selected-right');
- const selectedTextSpy = sinon.spy(element, '_getSelectedText');
- emulateCopyOn(element.querySelector('textarea'));
+ element.diffTable!.classList.add('selected-left');
+ element.diffTable!.classList.remove('selected-right');
+ const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('textarea'));
assert.isFalse(selectedTextSpy.called);
});
test('regression test for 4794', () => {
- element.classList.add('selected-right');
- element.classList.remove('selected-left');
+ element.diffTable!.classList.add('selected-right');
+ element.diffTable!.classList.remove('selected-left');
const selection = document.getSelection();
if (!selection) assert.fail('no selection');
selection.removeAllRanges();
const range = document.createRange();
range.setStart(
- element.querySelectorAll('div.contentText')[1]!.firstChild!,
+ diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
4
);
range.setEnd(
- element.querySelectorAll('div.contentText')[1]!.firstChild!,
+ diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
10
);
selection.addRange(range);
- assert.equal(element._getSelectedText(Side.RIGHT, false), ' other');
+ assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
});
test('copies to end of side (issue 7895)', () => {
- element.classList.add('selected-left');
- element.classList.remove('selected-right');
+ element.diffTable!.classList.add('selected-left');
+ element.diffTable!.classList.remove('selected-right');
const selection = document.getSelection();
if (selection === null) assert.fail('no selection');
selection.removeAllRanges();
const range = document.createRange();
- range.setStart(element.querySelector('div.contentText')!.firstChild!, 3);
+ range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
range.setEnd(
- element.querySelectorAll('div.contentText')[4]!.firstChild!,
+ diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
2
);
selection.addRange(range);
- assert.equal(element._getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+ assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
});
- suite('_getTextContentForRange', () => {
+ suite('getTextContentForRange', () => {
let selection: Selection;
let range: Range;
let nodes: NodeListOf<GrFormattedText>;
setup(() => {
- element.classList.add('selected-left');
- element.classList.add('selected-comment');
- element.classList.remove('selected-right');
+ element.diffTable!.classList.add('selected-left');
+ element.diffTable!.classList.add('selected-comment');
+ element.diffTable!.classList.remove('selected-right');
const s = document.getSelection();
if (s === null) assert.fail('no selection');
selection = s;
selection.removeAllRanges();
range = document.createRange();
- nodes = element.querySelectorAll('.gr-formatted-text *');
+ nodes = diffTable.querySelectorAll('.gr-formatted-text *');
});
test('multi level element contained in range', () => {
@@ -365,7 +362,7 @@
range.setEnd(nodes[2].childNodes[2], 7);
selection.addRange(range);
assert.equal(
- element._getTextContentForRange(element, selection, range),
+ element.getTextContentForRange(diffTable, selection, range),
'his is a differ'
);
});
@@ -375,7 +372,7 @@
range.setEnd(nodes[2].childNodes[2], 7);
selection.addRange(range);
assert.equal(
- element._getTextContentForRange(element, selection, range),
+ element.getTextContentForRange(diffTable, selection, range),
'a differ'
);
});
@@ -385,16 +382,9 @@
range.setEnd(nodes[0].firstChild!, 12);
selection.addRange(range);
assert.equal(
- element._getTextContentForRange(element, selection, range),
+ element.getTextContentForRange(diffTable, selection, range),
'is is a co'
);
});
});
-
- test('cache is reset when diff changes', () => {
- element._linesCache = {left: ['test'], right: ['test']};
- element.diff = createDiff();
- flush();
- assert.deepEqual(element._linesCache, {left: null, right: null});
- });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index a38ec91..34c2a33 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -80,6 +80,7 @@
import {isSafari, toggleClass} from '../../../utils/dom-util';
import {assertIsDefined} from '../../../utils/common-util';
import {debounce, DelayedTask} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
const NO_NEWLINE_LEFT = 'No newline at end of left file.';
const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -99,7 +100,6 @@
export interface GrDiff {
$: {
- highlights: GrDiffHighlight;
diffBuilder: GrDiffBuilderElement;
diffTable: HTMLTableElement;
};
@@ -294,6 +294,10 @@
private renderDiffTableTask?: DelayedTask;
+ private diffSelection = new GrDiffSelection();
+
+ private highlights = new GrDiffHighlight();
+
constructor() {
super();
this._setLoading(true);
@@ -315,6 +319,8 @@
this.renderDiffTableTask?.cancel();
this._unobserveIncrementalNodes();
this._unobserveNodes();
+ this.diffSelection.cleanup();
+ this.highlights.cleanup();
super.disconnectedCallback();
}
@@ -357,7 +363,7 @@
// and pass the shadow DOM selection into gr-diff-highlight, where the
// corresponding range is determined and normalized.
const selection = this._getShadowOrDocumentSelection();
- this.$.highlights.handleSelectionChange(selection, false);
+ this.highlights.handleSelectionChange(selection, false);
};
private readonly handleMouseUp = () => {
@@ -365,7 +371,7 @@
// mouse-up if there's a selection that just covers a line change. We
// can't do that on selection change since the user may still be dragging.
const selection = this._getShadowOrDocumentSelection();
- this.$.highlights.handleSelectionChange(selection, true);
+ this.highlights.handleSelectionChange(selection, true);
};
/** Gets the current selection, preferring the shadow DOM selection. */
@@ -404,7 +410,7 @@
const range = getRange(threadEl);
if (!range) return undefined;
- return {side, range, hovering: false, rootId: threadEl.rootId};
+ return {side, range, rootId: threadEl.rootId};
}
// TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
@@ -430,7 +436,6 @@
this.push('_commentRanges', {
side: Side.RIGHT,
range: this.highlightRange,
- hovering: true,
rootId: '',
});
}
@@ -498,7 +503,7 @@
}
isRangeSelected() {
- return !!this.$.highlights.selectedRange;
+ return !!this.highlights.selectedRange;
}
toggleLeftDiff() {
@@ -590,7 +595,7 @@
if (!this.isRangeSelected()) {
throw Error('Selection is needed for new range comment');
}
- const selectedRange = this.$.highlights.selectedRange;
+ const selectedRange = this.highlights.selectedRange;
if (!selectedRange) throw Error('selected range not set');
const {side, range} = selectedRange;
this._createCommentForSelection(side, range);
@@ -813,6 +818,10 @@
this._diffLength = this.getDiffLength(newValue);
this._debounceRenderDiffTable();
}
+ if (this.diff) {
+ this.diffSelection.init(this.diff, this.$.diffTable);
+ this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+ }
}
/**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index e05e85a..6d36b89 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -487,6 +487,10 @@
color: var(--link-color);
padding: var(--spacing-m) 0 var(--spacing-m) 48px;
}
+ #diffTable {
+ /* for gr-selection-action-box positioning */
+ position: relative;
+ }
#diffTable:focus {
outline: none;
}
@@ -670,6 +674,14 @@
.token-highlight {
background-color: var(--token-highlighting-color, #fffd54);
}
+
+ gr-selection-action-box {
+ /**
+ * Needs z-index to appear above wrapped content, since it's inserted
+ * into DOM before it.
+ */
+ z-index: 10;
+ }
</style>
<style include="gr-syntax-theme">
/* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -686,44 +698,36 @@
class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
on-click="_handleTap"
>
- <gr-diff-selection diff="[[diff]]">
- <gr-diff-highlight
- id="highlights"
- logged-in="[[loggedIn]]"
- comment-ranges="{{_commentRanges}}"
- >
- <gr-diff-builder
- id="diffBuilder"
- comment-ranges="[[_commentRanges]]"
- coverage-ranges="[[coverageRanges]]"
- diff="[[diff]]"
- path="[[path]]"
- view-mode="[[viewMode]]"
- is-image-diff="[[isImageDiff]]"
- base-image="[[baseImage]]"
- layers="[[layers]]"
- revision-image="[[revisionImage]]"
- use-new-image-diff-ui="[[useNewImageDiffUi]]"
- >
- <table
- id="diffTable"
- class$="[[_diffTableClass]]"
- role="presentation"
- contenteditable$="[[isContentEditable]]"
- ></table>
+ <gr-diff-builder
+ id="diffBuilder"
+ comment-ranges="[[_commentRanges]]"
+ coverage-ranges="[[coverageRanges]]"
+ diff="[[diff]]"
+ path="[[path]]"
+ view-mode="[[viewMode]]"
+ is-image-diff="[[isImageDiff]]"
+ base-image="[[baseImage]]"
+ layers="[[layers]]"
+ revision-image="[[revisionImage]]"
+ use-new-image-diff-ui="[[useNewImageDiffUi]]"
+ >
+ <table
+ id="diffTable"
+ class$="[[_diffTableClass]]"
+ role="presentation"
+ contenteditable$="[[isContentEditable]]"
+ ></table>
- <template
- is="dom-if"
- if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
- >
- <div class="whitespace-change-only-message">
- This file only contains whitespace changes. Modify the whitespace
- setting to see the changes.
- </div>
- </template>
- </gr-diff-builder>
- </gr-diff-highlight>
- </gr-diff-selection>
+ <template
+ is="dom-if"
+ if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+ >
+ <div class="whitespace-change-only-message">
+ This file only contains whitespace changes. Modify the whitespace
+ setting to see the changes.
+ </div>
+ </template>
+ </gr-diff-builder>
</div>
<div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
[[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
index ba4fa9f..0559811 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
@@ -25,6 +25,7 @@
import {Side} from '../../../api/diff.js';
import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
import {AbortStop} from '../../../api/core.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
const basicFixture = fixtureFromElement('gr-diff');
@@ -50,21 +51,21 @@
setup(() => {
element = basicFixture.instantiate();
- sinon.stub(element.$.highlights, 'handleSelectionChange');
+ sinon.stub(element.highlights, 'handleSelectionChange');
});
test('enabled if logged in', async () => {
element.loggedIn = true;
emulateSelection();
await flush();
- assert.isTrue(element.$.highlights.handleSelectionChange.called);
+ assert.isTrue(element.highlights.handleSelectionChange.called);
});
test('ignored if logged out', async () => {
element.loggedIn = false;
emulateSelection();
await flush();
- assert.isFalse(element.$.highlights.handleSelectionChange.called);
+ assert.isFalse(element.highlights.handleSelectionChange.called);
});
});
@@ -627,6 +628,7 @@
ab: Array(13).fill('text'),
}];
setupSampleDiff({content});
+ await new Promise(resolve => afterNextRender(element, resolve));
element.appendChild(threadEl);
await flush();
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 6c8a5e9..70cec64 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -20,7 +20,6 @@
export interface CommentRangeLayer {
side: Side;
range: CommentRange;
- hovering: boolean;
// New drafts don't have a rootId.
rootId?: string;
}
@@ -40,7 +39,6 @@
* highlights.
*/
interface CommentRangeLineLayer {
- hovering: boolean;
longRange: boolean;
id: string;
// start char (0-based)
@@ -59,7 +57,7 @@
const RANGE_BASE_ONLY = 'style-scope gr-diff range';
const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
+// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
export class GrRangedCommentLayer implements DiffLayer {
private knownRanges: CommentRangeLayer[] = [];
@@ -95,11 +93,8 @@
el,
range.start,
range.end - range.start,
- (range.hovering
- ? HOVER_HIGHLIGHT
- : range.longRange
- ? RANGE_BASE_ONLY
- : RANGE_HIGHLIGHT) + ` ${strToClassName(range.id)}`
+ (range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
+ ` ${strToClassName(range.id)}`
);
}
}
@@ -139,17 +134,15 @@
}
private addRange(commentRange: CommentRangeLayer) {
- const {side, range, hovering} = commentRange;
+ const {side, range} = commentRange;
const longRange = isLongCommentRange(range);
this.updateRangesMap({
side,
range,
- hovering,
- operation: (forLine, startChar, endChar, hovering) => {
+ operation: (forLine, startChar, endChar) => {
forLine.push({
start: startChar,
end: endChar,
- hovering,
id: id(commentRange),
longRange,
});
@@ -158,11 +151,10 @@
}
private removeRange(commentRange: CommentRangeLayer) {
- const {side, range, hovering} = commentRange;
+ const {side, range} = commentRange;
this.updateRangesMap({
side,
range,
- hovering,
operation: forLine => {
const index = forLine.findIndex(
lineRange => id(commentRange) === lineRange.id
@@ -175,21 +167,19 @@
private updateRangesMap(options: {
side: Side;
range: CommentRange;
- hovering: boolean;
operation: (
forLine: CommentRangeLineLayer[],
start: number,
- end: number,
- hovering: boolean
+ end: number
) => void;
}) {
- const {side, range, hovering, operation} = options;
+ const {side, range, operation} = options;
const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
for (let line = range.start_line; line <= range.end_line; line++) {
const forLine = forSide[line] || (forSide[line] = []);
const start = line === range.start_line ? range.start_character : 0;
const end = line === range.end_line ? range.end_character : -1;
- operation(forLine, start, end, hovering);
+ operation(forLine, start, end);
}
this.notifyUpdateRange(range.start_line, range.end_line, side);
}
@@ -199,25 +189,20 @@
const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
if (lineNum === 'FILE' || lineNum === 'LOST') return [];
const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
- return (
- ranges
- .map(range => {
- // Make a copy, so that the normalization below does not mess with
- // our map.
- range = {...range};
- range.end = range.end === -1 ? line.text.length : range.end;
+ return ranges.map(range => {
+ // Make a copy, so that the normalization below does not mess with
+ // our map.
+ range = {...range};
+ range.end = range.end === -1 ? line.text.length : range.end;
- // Normalize invalid ranges where the start is after the end but the
- // start still makes sense. Set the end to the end of the line.
- // @see Issue 5744
- if (range.start >= range.end && range.start < line.text.length) {
- range.end = line.text.length;
- }
+ // Normalize invalid ranges where the start is after the end but the
+ // start still makes sense. Set the end to the end of the line.
+ // @see Issue 5744
+ if (range.start >= range.end && range.start < line.text.length) {
+ range.end = line.text.length;
+ }
- return range;
- })
- // Sort the ranges so that hovering highlights are on top.
- .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
- );
+ return range;
+ });
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 4e35645..15d14e3 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -24,7 +24,6 @@
start_line: 36,
},
rootId: 'a',
- hovering: false,
};
const rangeB: CommentRangeLayer = {
@@ -36,7 +35,6 @@
start_line: 10,
},
rootId: 'b',
- hovering: false,
};
const rangeC: CommentRangeLayer = {
@@ -47,7 +45,6 @@
start_character: 5,
start_line: 100,
},
- hovering: false,
};
const rangeD: CommentRangeLayer = {
@@ -59,7 +56,6 @@
start_line: 55,
},
rootId: 'd',
- hovering: false,
};
const rangeE: CommentRangeLayer = {
@@ -70,7 +66,6 @@
start_character: 1,
start_line: 60,
},
- hovering: false,
};
suite('gr-ranged-comment-layer', () => {
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 3ddff60..a1b732f 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -30,6 +30,5 @@
CHECKS_DEVELOPER = 'UiFeature__checks_developer',
SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
- CHECK_RESULTS_IN_DIFFS = 'UiFeature__check_results_in_diffs',
DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
}
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index e99cf27..a83e897 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -138,6 +138,8 @@
box-sizing: border-box;
padding: var(--spacing-s);
}
+ --iron-autogrow-textarea_-_box-sizing: border-box;
+ --iron-autogrow-textarea_-_padding: var(--spacing-s);
}
a {
color: var(--link-color);
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index cc292e6..2fffc9a 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -33,6 +33,7 @@
CommentInfo,
CommentLinkInfo,
CommentLinks,
+ CommentRange,
CommitId,
CommitInfo,
ConfigInfo,
@@ -652,6 +653,15 @@
};
}
+export function createRange(): CommentRange {
+ return {
+ start_line: 1,
+ start_character: 0,
+ end_line: 1,
+ end_character: 1,
+ };
+}
+
export function createComment(
extra: Partial<CommentInfo | DraftInfo> = {}
): CommentInfo {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 0c63de0..985bec1 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -296,6 +296,19 @@
element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));
}
+export function mouseDown(element: HTMLElement) {
+ const rect = element.getBoundingClientRect();
+ const eventOptions = {
+ bubbles: true,
+ composed: true,
+ clientX: (rect.left + rect.right) / 2,
+ clientY: (rect.top + rect.bottom) / 2,
+ screenX: (rect.left + rect.right) / 2,
+ screenY: (rect.top + rect.bottom) / 2,
+ };
+ element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
+}
+
export function assertFails(promise: Promise<unknown>, error?: unknown) {
promise
.then((_v: unknown) => {