Merge "Add Reviewer REST endpoint: Document how to add multiple reviewers at once"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3459001..3e75a53 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -99,8 +99,9 @@
Gerrit comes with two predefined groups:
-* Administrators
-* Service Users
+* link:#administrators[Administrators]
+* link:#service_users[Service Users]
+* link:#blocked_users[Blocked Users]
[[administrators]]
@@ -138,6 +139,15 @@
Before Gerrit 3.3, the 'Service Users' group was named 'Non-Interactive Users'.
+[[blocked_users]]
+=== Blocked Users
+
+This is a predefined group, created on Gerrit site initialization, for which
+the link:#category_read[Read] access right is globally blocked.
+
+link:#administrators[Administrators] can add spammers to this group in order to
+block them from accessing Gerrit so that they cannot post any further spam.
+
== Account Groups
Account groups contain a list of zero or more user account members,
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 2367c21..a952d4e 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -19,6 +19,7 @@
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.block;
import static com.google.gerrit.server.schema.AclUtil.grant;
import static com.google.gerrit.server.schema.AclUtil.rule;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
@@ -33,6 +34,8 @@
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.Sequence;
import com.google.gerrit.server.config.AllProjectsName;
@@ -44,6 +47,7 @@
import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import java.io.IOException;
+import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -131,11 +135,16 @@
// init labels.
input.codeReviewLabel().ifPresent(codeReviewLabel -> config.upsertLabelType(codeReviewLabel));
+ // init access sections.
if (input.initDefaultAcls()) {
- // init access sections.
initDefaultAcls(config, input);
}
+ // init submit requirement sections.
+ if (input.initDefaultSubmitRequirements()) {
+ initDefaultSubmitRequirements(config);
+ }
+
// commit all the above configs as a commit in "refs/meta/config" branch of the All-Projects.
config.commitToNewRef(md, RefNames.REFS_CONFIG);
@@ -155,7 +164,10 @@
config.upsertAccessSection(
AccessSection.HEADS,
- heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
+ heads -> {
+ initDefaultAclsForAnonymousUsers(heads, config);
+ initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+ });
config.upsertAccessSection(
AccessSection.GLOBAL_CAPABILITIES,
@@ -167,28 +179,44 @@
initDefaultAclsForServiceUsers(capabilities, config, serviceUsersGroup)));
input
+ .blockedUsersGroup()
+ .ifPresent(blockedUsersGrouo -> initDefaultAclsForBlockedUsers(config, blockedUsersGrouo));
+
+ input
.administratorsGroup()
.ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
}
- private void initDefaultAclsForRegisteredUsers(
- AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
- config.upsertAccessSection(
- "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
+ private void initDefaultSubmitRequirements(ProjectConfig config) {
+ config.upsertSubmitRequirement(
+ SubmitRequirement.builder()
+ .setName("No-Unresolved-Comments")
+ .setDescription(
+ Optional.of("Changes that have unresolved comments are not submittable."))
+ .setApplicabilityExpression(SubmitRequirementExpression.of("has:unresolved"))
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ }
+
+ private void initDefaultAclsForAnonymousUsers(AccessSection.Builder heads, ProjectConfig config) {
+ grant(config, heads, Permission.READ, anonymous);
config.upsertAccessSection(
"refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
+ }
+ private void initDefaultAclsForRegisteredUsers(
+ AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
grant(config, heads, codeReviewLabel, -1, 1, registered);
grant(config, heads, Permission.FORGE_AUTHOR, registered);
- grant(config, heads, Permission.READ, anonymous);
- grant(config, heads, Permission.REVERT, registered);
config.upsertAccessSection(
- "refs/for/" + AccessSection.ALL,
- magic -> {
- grant(config, magic, Permission.PUSH, registered);
- grant(config, magic, Permission.PUSH_MERGE, registered);
+ "refs/for/*",
+ refsFor -> {
+ grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+ grant(config, refsFor, Permission.PUSH, registered);
+ grant(config, refsFor, Permission.PUSH_MERGE, registered);
});
}
@@ -201,6 +229,12 @@
stream.add(rule(config, serviceUsersGroup));
}
+ private void initDefaultAclsForBlockedUsers(
+ ProjectConfig config, GroupReference blockedUsersGroup) {
+ config.upsertAccessSection(
+ AccessSection.ALL, all -> block(config, all, Permission.READ, blockedUsersGroup));
+ }
+
private void initDefaultAclsForAdmins(
ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
config.upsertAccessSection(
@@ -220,6 +254,7 @@
grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+ grant(config, heads, Permission.REVERT, adminsGroup, owners);
});
config.upsertAccessSection(
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 8db5b1a..f692691 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -69,6 +69,9 @@
/** The group which gets stream-events permission granted and appropriate properties set. */
public abstract Optional<GroupReference> serviceUsersGroup();
+ /** The group for which read access gets blocked. */
+ public abstract Optional<GroupReference> blockedUsersGroup();
+
/** The commit message used when commit the project config change. */
public abstract Optional<String> commitMessage();
@@ -89,6 +92,9 @@
/** Whether initializing default access sections in All-Projects. */
public abstract boolean initDefaultAcls();
+ /** Whether default submit requirements should be initialized in All-Projects. */
+ public abstract boolean initDefaultSubmitRequirements();
+
public abstract Builder toBuilder();
public static Builder builder() {
@@ -96,7 +102,8 @@
new AutoValue_AllProjectsInput.Builder()
.codeReviewLabel(getDefaultCodeReviewLabel())
.firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
- .initDefaultAcls(true);
+ .initDefaultAcls(true)
+ .initDefaultSubmitRequirements(true);
DEFAULT_BOOLEAN_PROJECT_CONFIGS.forEach(builder::addBooleanProjectConfig);
return builder;
@@ -110,7 +117,9 @@
public abstract static class Builder {
public abstract Builder administratorsGroup(GroupReference adminGroup);
- public abstract Builder serviceUsersGroup(GroupReference serviceGroup);
+ public abstract Builder serviceUsersGroup(GroupReference serviceUsersGroup);
+
+ public abstract Builder blockedUsersGroup(GroupReference blockedUsersGroup);
public abstract Builder commitMessage(String commitMessage);
@@ -135,6 +144,8 @@
@UsedAt(UsedAt.Project.GOOGLE)
public abstract Builder initDefaultAcls(boolean initDefaultACLs);
+ public abstract Builder initDefaultSubmitRequirements(boolean initDefaultSubmitRequirements);
+
public abstract AllProjectsInput build();
}
}
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 5b54cfd..cf3948a 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -92,11 +92,13 @@
try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
GroupReference admins = createGroupReference("Administrators");
GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference blockedUsers = createGroupReference("Blocked Users");
AllProjectsInput allProjectsInput =
AllProjectsInput.builder()
.administratorsGroup(admins)
.serviceUsersGroup(serviceUsers)
+ .blockedUsersGroup(blockedUsers)
.build();
allProjectsCreator.create(allProjectsInput);
// We have to create the All-Users repository before we can use it to store the groups in it.
@@ -105,6 +107,7 @@
try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
createAdminsGroup(allUsersRepo, admins);
createServiceUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+ createBlockedUsersGroup(allUsersRepo, blockedUsers, admins.getUUID());
}
}
}
@@ -141,6 +144,19 @@
createGroup(allUsersRepo, groupCreation, groupDelta);
}
+ private void createBlockedUsersGroup(
+ Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
+ throws IOException, ConfigInvalidException {
+ InternalGroupCreation groupCreation = getGroupCreation(groupReference);
+ GroupDelta groupDelta =
+ GroupDelta.builder()
+ .setDescription("Blocked users. Add spammers to this group.")
+ .setOwnerGroupUUID(adminsGroupUuid)
+ .build();
+
+ createGroup(allUsersRepo, groupCreation, groupDelta);
+ }
+
private void createGroup(
Repository allUsersRepo, InternalGroupCreation groupCreation, GroupDelta groupDelta)
throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 7243bdf..5c8abf9 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,15 @@
ImmutableList.of(
"[access \"refs/*\"]",
" read = group Administrators",
+ " read = block group Blocked Users",
"[access \"refs/for/*\"]",
" addPatchSet = group Registered Users",
- "[access \"refs/for/refs/*\"]",
" push = group Registered Users",
" pushMerge = group Registered Users",
"[access \"refs/heads/*\"]",
" read = group Anonymous Users",
- " revert = group Registered Users",
+ " revert = group Administrators",
+ " revert = group Project Owners",
" create = group Administrators",
" create = group Project Owners",
" editTopicName = +force group Administrators",
@@ -108,6 +109,13 @@
" value = 0 No score",
" value = +1 Looks good to me, but someone else must approve",
" value = +2 Looks good to me, approved");
+ private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION =
+ ImmutableList.of(
+ "[submit-requirement \"No-Unresolved-Comments\"]",
+ " description = Changes that have unresolved comments are not submittable.",
+ " applicableIf = has:unresolved",
+ " submittableIf = -has:unresolved",
+ " canOverrideInChildProjects = false");
public static String getDefaultAllProjectsWithAllDefaultSections() {
return Streams.stream(
@@ -117,7 +125,8 @@
DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
- DEFAULT_ALL_PROJECTS_LABEL_SECTION))
+ DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+ DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
.collect(Collectors.joining("\n"));
}
@@ -127,6 +136,19 @@
DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+ DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+ DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
+ .collect(Collectors.joining("\n"));
+ }
+
+ public static String getAllProjectsWithoutDefaultSubmitRequirements() {
+ return Streams.stream(
+ Iterables.concat(
+ DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+ DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+ DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+ DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+ DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
DEFAULT_ALL_PROJECTS_LABEL_SECTION))
.collect(Collectors.joining("\n"));
}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index ead4c40..f462614 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,6 +16,8 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Comparator.comparing;
@@ -26,6 +28,7 @@
import com.google.gerrit.acceptance.UseClockStep;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.entities.AccountGroup;
@@ -33,6 +36,7 @@
import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -63,6 +67,7 @@
private ContributorAgreement caAutoVerify;
private ContributorAgreement caNoAutoVerify;
@Inject private GroupOperations groupOperations;
+ @Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
@@ -298,6 +303,11 @@
gApi.changes().id(change.changeId).current().submit(new SubmitInput());
// Revert in excluded project is allowed even when CLA is required but not signed
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
requestScopeOperations.setApiUser(user.id());
setUseContributorAgreements(InheritableBoolean.TRUE);
gApi.changes().id(change.changeId).revert();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
new file mode 100644
index 0000000..fc8eaed
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
@@ -0,0 +1,79 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.util.Comparator;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class DefaultSubmitRequirementsIT extends AbstractDaemonTest {
+ /**
+ * Tests the "No-Unresolved-Comments" submit requirement that is created during the site
+ * initialization.
+ */
+ @Test
+ public void cannotSubmitChangeWithUnresolvedComment() throws Exception {
+ TestRepository<InMemoryRepository> repo = cloneProject(project);
+ PushOneCommit.Result r =
+ createChange(repo, "master", "Add a file", "foo", "content", /* topic= */ null);
+ String changeId = r.getChangeId();
+ CommentInfo commentInfo =
+ addComment(changeId, "foo", "message", /* unresolved= */ true, /* inReplyTo= */ null);
+ assertThat(commentInfo.unresolved).isTrue();
+ approve(changeId);
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Failed to submit 1 change due to the following problems:\n"
+ + "Change %s: submit requirement 'No-Unresolved-Comments' is unsatisfied.",
+ r.getChange().getId().get()));
+
+ // Resolve the comment and check that the change can be submitted now.
+ CommentInfo commentInfo2 =
+ addComment(
+ changeId, "foo", "reply", /* unresolved= */ false, /* inReplyTo= */ commentInfo.id);
+ assertThat(commentInfo2.unresolved).isFalse();
+ gApi.changes().id(changeId).current().submit();
+ }
+
+ @CanIgnoreReturnValue
+ private CommentInfo addComment(
+ String changeId, String file, String message, boolean unresolved, @Nullable String inReplyTo)
+ throws Exception {
+ ReviewInput in = new ReviewInput();
+ CommentInput commentInput = new CommentInput();
+ commentInput.path = file;
+ commentInput.line = 1;
+ commentInput.message = message;
+ commentInput.unresolved = unresolved;
+ commentInput.inReplyTo = inReplyTo;
+ in.comments = ImmutableMap.of(file, ImmutableList.of(commentInput));
+ gApi.changes().id(changeId).current().review(in);
+
+ return gApi.changes().id(changeId).commentsRequest().getAsList().stream()
+ .filter(commentInfo -> commentInput.message.equals(commentInfo.message))
+ // if there are multiple comments with the same message, take the one was created last
+ .max(
+ Comparator.comparing(commentInfo1 -> commentInfo1.updated.toInstant().getEpochSecond()))
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format("comment '%s' not found", commentInput.message)));
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index cd3e76d..10dd5e3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.api.change;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -413,6 +414,12 @@
@Test
@GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
public void revertWithNonVisibleUsers() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
// Define readable names for the users we use in this test.
TestAccount reverter = user;
TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 42af666..a5d86ce 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -92,6 +92,7 @@
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Before;
import org.junit.Test;
@NoHttpd
@@ -103,6 +104,11 @@
@Inject private ExtensionRegistry extensionRegistry;
@Inject private IndexOperations.Change changeIndexOperations;
+ @Before
+ public void setup() throws RestApiException {
+ removeDefaultSubmitRequirements();
+ }
+
@Test
public void submitRecords() throws Exception {
PushOneCommit.Result r = createChange();
@@ -3160,4 +3166,8 @@
r.assertOkStatus();
return r;
}
+
+ private void removeDefaultSubmitRequirements() throws RestApiException {
+ gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
index e388dd1..6d4da66 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -582,7 +582,7 @@
infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
- assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+ assertThat(names(infos)).containsExactly("No-Unresolved-Comments", "base-sr", "sr-1", "sr-2");
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 6c5febd..c18cd55 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -87,7 +87,8 @@
@Inject private IndexOperations.Change changeIndexOperations;
private AccountGroup.UUID admins;
- private AccountGroup.UUID nonInteractiveUsers;
+ private AccountGroup.UUID serviceUsers;
+ private AccountGroup.UUID blockedUsers;
private RevCommit rcMaster;
private RevCommit rcBranch;
@@ -118,7 +119,8 @@
@Before
public void setUp() throws Exception {
admins = adminGroupUuid();
- nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+ serviceUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+ blockedUsers = groupUuid("Blocked Users");
setUpPermissions();
setUpChanges();
}
@@ -1239,7 +1241,8 @@
assertThat(getGroupRefs(git))
.containsExactly(
RefNames.refsGroups(admins),
- RefNames.refsGroups(nonInteractiveUsers),
+ RefNames.refsGroups(serviceUsers),
+ RefNames.refsGroups(blockedUsers),
RefNames.refsGroups(users));
}
}
@@ -1261,7 +1264,8 @@
assertThat(getGroupRefs(git))
.containsExactly(
RefNames.refsGroups(admins),
- RefNames.refsGroups(nonInteractiveUsers),
+ RefNames.refsGroups(serviceUsers),
+ RefNames.refsGroups(blockedUsers),
RefNames.refsGroups(users));
}
}
@@ -1413,7 +1417,8 @@
RefNames.REFS_EXTERNAL_IDS,
RefNames.REFS_GROUPNAMES,
RefNames.refsGroups(admins),
- RefNames.refsGroups(nonInteractiveUsers),
+ RefNames.refsGroups(serviceUsers),
+ RefNames.refsGroups(blockedUsers),
RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS,
RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS,
RefNames.REFS_CONFIG,
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 4453345..e58757b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -34,6 +34,6 @@
newGson()
.fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
assertThat(groupMap.keySet())
- .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
+ .containsExactly("Administrators", "Blocked Users", ServiceUserClassifier.SERVICE_USERS);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 2ee5360..d88db52 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -2369,6 +2369,12 @@
@Test
public void revertChangeByOwner() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, sc.owner);
@@ -2394,6 +2400,12 @@
@Test
public void revertChangeByOwnerCcingSelf() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
@@ -2420,6 +2432,12 @@
@Test
public void revertChangeByOther() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, other);
@@ -2446,6 +2464,12 @@
@Test
public void revertChangeByOtherCcingSelf() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, other, CC_ON_OWN_COMMENTS);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
index 50fa3b2..3041744 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -33,6 +33,7 @@
import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.change.TestSubmitInput;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
@@ -64,6 +65,7 @@
@Before
public void setUp() throws Exception {
+ removeDefaultSubmitRequirements();
TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
configSubmitRequirement(
project,
@@ -242,4 +244,8 @@
.count())
.isEqualTo(1);
}
+
+ private void removeDefaultSubmitRequirements() throws RestApiException {
+ gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 0c24b14..b5a3b66 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -75,6 +75,7 @@
@Before
public void setUp() throws Exception {
+ removeDefaultSubmitRequirements();
PushOneCommit.Result pushResult =
createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
changeData = pushResult.getChange();
@@ -976,6 +977,10 @@
.build();
}
+ private void removeDefaultSubmitRequirements() throws RestApiException {
+ gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+ }
+
/** Submit requirement predicate that always throws an error on match. */
static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
implements ChangeIsOperandFactory {
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index 6c79c43..3fae3ad 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -18,6 +18,7 @@
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultSubmitRequirements;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
import static com.google.gerrit.truth.ConfigSubject.assertThat;
@@ -89,11 +90,13 @@
expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
GroupReference adminsGroup = createGroupReference("Administrators");
- GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference blockedUsersGroup = createGroupReference("Blocked Users");
AllProjectsInput allProjectsInput =
AllProjectsInput.builder()
.administratorsGroup(adminsGroup)
- .serviceUsersGroup(batchUsersGroup)
+ .serviceUsersGroup(serviceUsersGroup)
+ .blockedUsersGroup(blockedUsersGroup)
.build();
allProjectsCreator.create(allProjectsInput);
@@ -139,6 +142,7 @@
.addBooleanProjectConfig(
BooleanProjectConfig.REJECT_EMPTY_COMMIT, InheritableBoolean.TRUE)
.initDefaultAcls(true)
+ .initDefaultSubmitRequirements(true)
.build();
allProjectsCreator.create(allProjectsInput);
@@ -158,6 +162,26 @@
}
@Test
+ public void createAllProjectsWithoutInitializingDefaultSubmitRequirements() throws Exception {
+ GroupReference adminsGroup = createGroupReference("Administrators");
+ GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference blockedUsersGroup = createGroupReference("Blocked Users");
+ AllProjectsInput allProjectsInput =
+ AllProjectsInput.builder()
+ .administratorsGroup(adminsGroup)
+ .serviceUsersGroup(serviceUsersGroup)
+ .blockedUsersGroup(blockedUsersGroup)
+ .initDefaultSubmitRequirements(false)
+ .build();
+ allProjectsCreator.create(allProjectsInput);
+
+ Config expectedConfig = new Config();
+ expectedConfig.fromText(getAllProjectsWithoutDefaultSubmitRequirements());
+ Config config = readAllProjectsConfig(repoManager, allProjectsName);
+ assertTwoConfigsEquivalent(config, expectedConfig);
+ }
+
+ @Test
public void createAllProjectsOnlyInitializingProjectDescription() throws Exception {
String description = "a project.config with just a project description";
AllProjectsInput allProjectsInput =
@@ -165,6 +189,7 @@
.firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
.projectDescription(description)
.initDefaultAcls(false)
+ .initDefaultSubmitRequirements(false)
.build();
allProjectsCreator.create(allProjectsInput);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index ad3e15c..30f4bf8 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -458,7 +458,9 @@
// app.
assign(
window.location,
- '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+ `${basePath}/login/${encodeURIComponent(
+ returnUrl.substring(basePath.length)
+ )}`
);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 51613e8..863f4f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -87,6 +87,8 @@
import {getFileExtension} from '../../../utils/file-util';
import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
import {deepEqual} from '../../../utils/deep-util';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {waitUntil} from '../../../utils/async-util';
// visible for testing
export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -155,6 +157,9 @@
@query('#confirmDeleteCommentDialog')
confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
+ @query('#suggestionDiffPreview')
+ suggestionDiffPreview?: GrSuggestionDiffPreview;
+
@property({type: Object})
comment?: Comment;
@@ -1059,6 +1064,7 @@
if (this.generatedFixSuggestion) {
return html`<gr-suggestion-diff-preview
+ id="suggestionDiffPreview"
.fixSuggestionInfo=${this.generatedFixSuggestion}
></gr-suggestion-diff-preview>`;
} else if (this.generatedSuggestion) {
@@ -1269,7 +1275,13 @@
return;
}
this.generatedFixSuggestion = suggestion;
- this.autoSaveTrigger$.next();
+ try {
+ await waitUntil(() => this.getFixSuggestions() !== undefined);
+ this.autoSaveTrigger$.next();
+ } catch (error) {
+ // Error is ok in some cases like quick save by user.
+ console.warn(error);
+ }
}
private renderRobotActions() {
@@ -1682,7 +1694,7 @@
isError(this.comment) ||
this.messageText.trimEnd() !== this.comment.message ||
this.unresolved !== this.comment.unresolved ||
- !deepEqual(this.comment.fix_suggestions, this.getFixSuggestions())
+ this.isFixSuggestionChanged()
);
}
@@ -1690,15 +1702,22 @@
private rawSave(options: {showToast: boolean}) {
assert(isDraft(this.comment), 'only drafts are editable');
assert(!isSaving(this.comment), 'saving already in progress');
- return this.getCommentsModel().saveDraft(
- {
- ...this.comment,
- message: this.messageText.trimEnd(),
- unresolved: this.unresolved,
- fix_suggestions: this.getFixSuggestions(),
- },
- options.showToast
- );
+ const draft: DraftInfo = {
+ ...this.comment,
+ message: this.messageText.trimEnd(),
+ unresolved: this.unresolved,
+ };
+ if (this.isFixSuggestionChanged()) {
+ draft.fix_suggestions = this.getFixSuggestions();
+ }
+ return this.getCommentsModel().saveDraft(draft, options.showToast);
+ }
+
+ isFixSuggestionChanged(): boolean {
+ // Check to not change fix suggestion when draft is not being edited only
+ // when user quickly disable generating suggestions and click save
+ if (!this.editing && this.generateSuggestion) return false;
+ return !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions());
}
getFixSuggestions(): FixSuggestionInfo[] | undefined {
@@ -1708,6 +1727,13 @@
if (!this.generatedFixSuggestion) return undefined;
// Disable fix suggestions when the comment already has a user suggestion
if (this.comment && hasUserSuggestion(this.comment)) return undefined;
+ // we ignore fixSuggestions until they are previewed.
+ if (
+ this.suggestionDiffPreview &&
+ !this.suggestionDiffPreview?.previewed &&
+ !this.suggestionLoading
+ )
+ return undefined;
return [this.generatedFixSuggestion];
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index aebc638..ef02c95 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -1081,7 +1081,7 @@
await element.updateComplete;
assert.dom.equal(
queryAndAssert(element, 'gr-suggestion-diff-preview'),
- /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+ /* HTML */ '<gr-suggestion-diff-preview id="suggestionDiffPreview"> </gr-suggestion-diff-preview>'
);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 923a00e..8314912 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -62,6 +62,9 @@
@property({type: Boolean})
showAddSuggestionButton = false;
+ @property({type: Boolean, attribute: 'previewed', reflect: true})
+ previewed = false;
+
@property({type: String})
uuid?: string;
@@ -270,6 +273,7 @@
)
return;
+ this.previewed = false;
this.reporting.time(Timing.PREVIEW_FIX_LOAD);
const res = await this.restApiService.getFixPreview(
this.changeNum,
@@ -287,6 +291,7 @@
if (currentPreviews.length > 0) {
this.preview = currentPreviews[0];
this.previewLoadedFor = this.fixSuggestionInfo;
+ this.previewed = true;
}
return res;
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6de43ed..cbb2d8c 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -13,6 +13,7 @@
import '../api/embed';
import '../scripts/bundled-polymer';
import './diff/gr-diff/gr-diff';
+import './gr-textarea';
import './diff/gr-diff-cursor/gr-diff-cursor';
import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
new file mode 100644
index 0000000..35dc2d1
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -0,0 +1,788 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+
+/**
+ * Waits for the next animation frame.
+ */
+async function animationFrame(): Promise<void> {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+}
+
+/**
+ * Whether the current browser supports `plaintext-only` for contenteditable
+ * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
+ */
+function supportsPlainTextEditing() {
+ const div = document.createElement('div');
+ try {
+ div.contentEditable = 'PLAINTEXT-ONLY';
+ return div.contentEditable === 'plaintext-only';
+ } catch (e) {
+ return false;
+ }
+}
+
+/** Input custom event detail object. */
+export interface InputEventDetail {
+ value: string;
+}
+
+/** Cursor position change custom event detail object.
+ *
+ * The current position of the cursor.
+ */
+export interface CursorPositionChangeEventDetail {
+ position: number;
+}
+
+/** hint shown custom event detail object */
+export interface HintShownEventDetail {
+ hint: string;
+}
+
+/** hint dismissed custom event detail object */
+export interface HintDismissedEventDetail {
+ hint: string;
+}
+
+/** hint applied custom event detail object */
+export interface HintAppliedEventDetail {
+ hint: string;
+ oldValue: string;
+}
+
+/** Class for autocomplete hint */
+export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
+
+const ACCEPT_PLACEHOLDER_HINT_LABEL =
+ 'Press TAB to accept the placeholder hint.';
+
+/**
+ * A custom textarea component which allows autocomplete functionality.
+ * This component is only supported in Chrome. Other browsers are not supported.
+ *
+ * Example usage:
+ * <gr-textarea></gr-textarea>
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends LitElement {
+ // editableDivElement is available right away where it may be undefined. This
+ // is used for calls for scrollTop as if it is undefined then we can fallback
+ // to 0. For other usecases use editableDiv.
+ @query('.editableDiv')
+ private readonly editableDivElement?: HTMLDivElement;
+
+ @queryAsync('.editableDiv')
+ private readonly editableDiv?: Promise<HTMLDivElement>;
+
+ @property({type: Boolean, reflect: true}) disabled = false;
+
+ @property({type: String, reflect: true}) placeholder: string | undefined;
+
+ /**
+ * The hint is shown as a autocomplete string which can be added by pressing
+ * TAB.
+ *
+ * The hint is shown
+ * 1. At the cursor position, only when cursor position is at the end of
+ * textarea content.
+ * 2. When textarea has focus.
+ * 3. When selection inside the textarea is collapsed.
+ *
+ * When hint is applied listen for hintApplied event and remove the hint
+ * as component property to avoid showing the hint again.
+ */
+ @property({type: String})
+ set hint(newHint) {
+ if (this.hint !== newHint) {
+ this.innerHint = newHint;
+ this.updateHintInDomIfRendered();
+ }
+ }
+
+ get hint() {
+ return this.innerHint;
+ }
+
+ /**
+ * Show hint is shown as placeholder which people can autocomplete to.
+ *
+ * This takes precedence over hint property.
+ * It is shown even when textarea has no focus.
+ * This is shown only when textarea is blank.
+ */
+ @property({type: String}) placeholderHint: string | undefined;
+
+ /**
+ * Sets the value for textarea and also renders it in dom if it is different
+ * from last rendered value.
+ *
+ * To prevent cursor position from jumping to front of text even when value
+ * remains same, Check existing value before triggering the update and only
+ * update when there is a change.
+ *
+ * Also .innerText binding can't be used for security reasons.
+ */
+ @property({type: String})
+ set value(newValue) {
+ if (this.ignoreValue && this.ignoreValue === newValue) {
+ return;
+ }
+ const oldVal = this.value;
+ if (oldVal !== newValue) {
+ this.innerValue = newValue;
+ this.updateValueInDom();
+ }
+ }
+
+ get value() {
+ return this.innerValue;
+ }
+
+ /**
+ * This value will be ignored by textarea and is not set.
+ */
+ @property({type: String}) ignoreValue: string | undefined;
+
+ /**
+ * Sets cursor at the end of content on focus.
+ */
+ @property({type: Boolean}) putCursorAtEndOnFocus = false;
+
+ /**
+ * Enables save shortcut.
+ *
+ * On S key down with control or meta key enabled is exposed with output event
+ * 'saveShortcut'.
+ */
+ @property({type: Boolean}) enableSaveShortcut = false;
+
+ /*
+ * Is textarea focused. This is a readonly property.
+ */
+ get isFocused(): boolean {
+ return this.focused;
+ }
+
+ /**
+ * Native element for editable div.
+ */
+ get nativeElement() {
+ return this.editableDivElement;
+ }
+
+ /**
+ * Scroll Top for editable div.
+ */
+ override get scrollTop() {
+ return this.editableDivElement?.scrollTop ?? 0;
+ }
+
+ private innerValue: string | undefined;
+
+ private innerHint: string | undefined;
+
+ private focused = false;
+
+ private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
+
+ static override get styles() {
+ return [
+ css`
+ :host {
+ display: inline-block;
+ position: relative;
+ width: 100%;
+ }
+
+ :host([disabled]) {
+ .editableDiv {
+ background-color: var(--input-field-disabled-bg, lightgrey);
+ color: var(--text-disabled, black);
+ cursor: default;
+ }
+ }
+
+ .editableDiv {
+ background-color: var(--input-field-bg, white);
+ border: 2px solid var(--onedev-textarea-border-color, white);
+ border-radius: 4px;
+ box-sizing: border-box;
+ color: var(--text-default, black);
+ max-height: var(--onedev-textarea-max-height, 16em);
+ min-height: var(--onedev-textarea-min-height, 4em);
+ overflow-x: auto;
+ padding: 12px;
+ white-space: pre-wrap;
+ width: 100%;
+
+ &:focus-visible {
+ border-color: var(--onedev-textarea-focus-outline-color, black);
+ outline: none;
+ }
+
+ &:empty::before {
+ content: attr(data-placeholder);
+ color: var(--text-secondary, lightgrey);
+ display: inline;
+ pointer-events: none;
+ }
+
+ &.hintShown:empty::after,
+ .autocomplete-hint:empty::after {
+ background-color: var(--secondary-bg-color, white);
+ border: 1px solid var(--text-secondary, lightgrey);
+ border-radius: 2px;
+ content: 'tab';
+ color: var(--text-secondary, lightgrey);
+ display: inline;
+ pointer-events: none;
+ font-size: 10px;
+ line-height: 10px;
+ margin-left: 4px;
+ padding: 1px 3px;
+ }
+
+ .autocomplete-hint {
+ &:empty::before {
+ content: attr(data-hint);
+ color: var(--text-secondary, lightgrey);
+ }
+ }
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ const isHintShownAsPlaceholder =
+ (!this.disabled && this.placeholderHint) ?? false;
+
+ const placeholder = isHintShownAsPlaceholder
+ ? this.placeholderHint
+ : this.placeholder;
+ const ariaPlaceholder = isHintShownAsPlaceholder
+ ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
+ : placeholder;
+
+ const classes = classMap({
+ editableDiv: true,
+ hintShown: isHintShownAsPlaceholder,
+ });
+
+ // Chrome supports non-standard "contenteditable=plaintext-only",
+ // which prevents HTML from being inserted into a contenteditable element.
+ // https://github.com/w3c/editing/issues/162
+ return html`<div
+ aria-disabled=${this.disabled}
+ aria-multiline="true"
+ aria-placeholder=${ifDefined(ariaPlaceholder)}
+ data-placeholder=${ifDefined(placeholder)}
+ class=${classes}
+ contenteditable=${this.contentEditableAttributeValue}
+ dir="ltr"
+ role="textbox"
+ @input=${this.onInput}
+ @focus=${this.onFocus}
+ @blur=${this.onBlur}
+ @keydown=${this.handleKeyDown}
+ @keyup=${this.handleKeyUp}
+ @mouseup=${this.handleMouseUp}
+ @scroll=${this.handleScroll}
+ ></div>`;
+ }
+
+ /**
+ * Focuses the textarea.
+ */
+ override async focus() {
+ const editableDivElement = await this.editableDiv;
+ const isFocused = this.isFocused;
+ editableDivElement?.focus?.();
+ // If already focused, do not change the cursor position.
+ if (this.putCursorAtEndOnFocus && !isFocused) {
+ await this.putCursorAtEnd();
+ }
+ }
+
+ /**
+ * Puts the cursor at the end of existing content.
+ * Scrolls the content of textarea towards the end.
+ */
+ async putCursorAtEnd() {
+ const editableDivElement = await this.editableDiv;
+ const selection = this.getSelection();
+
+ if (!editableDivElement || !selection) {
+ return;
+ }
+
+ const range = document.createRange();
+ editableDivElement.focus();
+ range.setStart(editableDivElement, editableDivElement.childNodes.length);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ this.scrollToCursorPosition(range);
+
+ range.detach();
+
+ await this.onCursorPositionChange(null);
+ }
+
+ /**
+ * Sets cursor position to given position and scrolls the content to cursor
+ * position.
+ *
+ * If position is out of bounds of value of textarea then cursor is places at
+ * end of content of textarea.
+ */
+ async setCursorPosition(position: number) {
+ // This will keep track of remaining offset to place the cursor.
+ let remainingOffset = position;
+ let isOnFreshLine = true;
+ let nodeToFocusOn: Node | null = null;
+ const editableDivElement = await this.editableDiv;
+ const selection = this.getSelection();
+
+ if (!editableDivElement || !selection) {
+ return;
+ }
+ editableDivElement.focus();
+ const findNodeToFocusOn = (childNodes: Node[]) => {
+ for (let i = 0; i < childNodes.length; i++) {
+ const childNode = childNodes[i];
+ let currentNodeLength = 0;
+
+ if (childNode.nodeName === 'BR') {
+ currentNodeLength++;
+ isOnFreshLine = true;
+ }
+
+ if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+ currentNodeLength++;
+ }
+
+ isOnFreshLine = false;
+
+ if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
+ currentNodeLength += childNode.textContent.length;
+ }
+
+ if (remainingOffset <= currentNodeLength) {
+ nodeToFocusOn = childNode;
+ break;
+ } else {
+ remainingOffset -= currentNodeLength;
+ }
+
+ if (childNode.childNodes?.length > 0) {
+ findNodeToFocusOn(Array.from(childNode.childNodes));
+ }
+ }
+ };
+
+ // Find the node to focus on.
+ findNodeToFocusOn(Array.from(editableDivElement.childNodes));
+
+ await this.setFocusOnNode(
+ selection,
+ editableDivElement,
+ nodeToFocusOn,
+ remainingOffset
+ );
+ }
+
+ /**
+ * Replaces text from start and end cursor position.
+ */
+ setRangeText(replacement: string, start: number, end: number) {
+ const pre = this.value?.substring(0, start) ?? '';
+ const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
+
+ this.value = pre + replacement + post;
+ this.setCursorPosition(pre.length + replacement.length);
+ }
+
+ private get contentEditableAttributeValue() {
+ return this.disabled
+ ? 'false'
+ : this.isPlaintextOnlySupported
+ ? ('plaintext-only' as unknown as 'true')
+ : 'true';
+ }
+
+ private async setFocusOnNode(
+ selection: Selection,
+ editableDivElement: Node,
+ nodeToFocusOn: Node | null,
+ remainingOffset: number
+ ) {
+ const range = document.createRange();
+ // If node is null or undefined then fallback to focus event which will put
+ // cursor at the end of content.
+ if (nodeToFocusOn === null) {
+ range.setStart(editableDivElement, editableDivElement.childNodes.length);
+ }
+ // If node to focus is BR then focus offset is number of nodes.
+ else if (nodeToFocusOn.nodeName === 'BR') {
+ const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
+ range.setEnd(nextNode, 0);
+ } else {
+ range.setStart(nodeToFocusOn, remainingOffset);
+ }
+
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Scroll the content to cursor position.
+ this.scrollToCursorPosition(range);
+
+ range.detach();
+
+ await this.onCursorPositionChange(null);
+ }
+
+ private async onInput(event: Event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ const value = await this.getValue();
+ this.innerValue = value;
+
+ this.dispatchEvent(
+ new CustomEvent('input', {
+ detail: {
+ value: this.value,
+ },
+ })
+ );
+ }
+
+ private async onFocus(event: Event) {
+ this.focused = true;
+ await this.onCursorPositionChange(event);
+ }
+
+ private async onBlur(event: Event) {
+ this.focused = false;
+ this.removeHintSpanIfShown();
+ await this.onCursorPositionChange(event);
+ }
+
+ private async handleKeyDown(event: KeyboardEvent) {
+ if (
+ event.key === 'Tab' &&
+ !event.shiftKey &&
+ !event.ctrlKey &&
+ !event.metaKey
+ ) {
+ await this.handleTabKeyPress(event);
+ return;
+ }
+ if (
+ this.enableSaveShortcut &&
+ event.key === 's' &&
+ (event.ctrlKey || event.metaKey)
+ ) {
+ event.preventDefault();
+ this.dispatchEvent(new CustomEvent('saveShortcut'));
+ }
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private async handleKeyUp(event: KeyboardEvent) {
+ await this.onCursorPositionChange(event);
+ }
+
+ private async handleMouseUp(event: MouseEvent) {
+ await this.onCursorPositionChange(event);
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private handleScroll() {
+ this.dispatchEvent(new CustomEvent('scroll'));
+ }
+
+ private async handleTabKeyPress(event: KeyboardEvent) {
+ const oldValue = this.value;
+ if (this.placeholderHint && !oldValue) {
+ event.preventDefault();
+ await this.appendHint(this.placeholderHint, event);
+ } else if (this.hasHintSpan()) {
+ event.preventDefault();
+ await this.appendHint(this.hint!, event);
+ }
+ }
+
+ private async appendHint(hint: string, event: Event) {
+ const oldValue = this.value ?? '';
+ const newValue = oldValue + hint;
+
+ this.value = newValue;
+ await this.putCursorAtEnd();
+ await this.onInput(event);
+
+ this.dispatchEvent(
+ new CustomEvent('hintApplied', {
+ detail: {
+ hint,
+ oldValue,
+ },
+ })
+ );
+ }
+
+ private async toggleHintVisibilityIfAny() {
+ // Wait for the next animation frame so that entered key is processed and
+ // available in dom.
+ await animationFrame();
+
+ const editableDivElement = await this.editableDiv;
+ const currentValue = (await this.getValue()) ?? '';
+ const cursorPosition = await this.getCursorPosition();
+ if (
+ !editableDivElement ||
+ (this.placeholderHint && !currentValue) ||
+ !this.hint ||
+ !this.isFocused ||
+ cursorPosition !== currentValue.length
+ ) {
+ this.removeHintSpanIfShown();
+ return;
+ }
+
+ const hintSpan = this.hintSpan();
+ if (!hintSpan) {
+ this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+ return;
+ }
+
+ const oldHint = (hintSpan as HTMLElement).dataset['hint'];
+ if (oldHint !== this.hint) {
+ this.removeHintSpanIfShown();
+ this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+ }
+ }
+
+ private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+ const hintSpan = document.createElement('span');
+ hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
+ hintSpan.setAttribute('role', 'alert');
+ hintSpan.setAttribute(
+ 'aria-label',
+ 'Suggestion: ' + hint + ' Press TAB to accept it.'
+ );
+ hintSpan.dataset['hint'] = hint;
+ editableDivElement.appendChild(hintSpan);
+ this.dispatchEvent(
+ new CustomEvent('hintShown', {
+ detail: {
+ hint,
+ },
+ })
+ );
+ }
+
+ private removeHintSpanIfShown() {
+ const hintSpan = this.hintSpan();
+ if (hintSpan) {
+ hintSpan.remove();
+ this.dispatchEvent(
+ new CustomEvent('hintDismissed', {
+ detail: {
+ hint: (hintSpan as HTMLElement).dataset['hint'],
+ },
+ })
+ );
+ }
+ }
+
+ private hasHintSpan() {
+ return !!this.hintSpan();
+ }
+
+ private hintSpan() {
+ return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
+ }
+
+ private async onCursorPositionChange(event: Event | null) {
+ event?.preventDefault();
+ event?.stopImmediatePropagation();
+
+ this.dispatchEvent(
+ new CustomEvent('cursorPositionChange', {
+ detail: {
+ position: await this.getCursorPosition(),
+ },
+ })
+ );
+ }
+
+ private async updateValueInDom() {
+ const editableDivElement = await this.editableDiv;
+ if (editableDivElement) {
+ editableDivElement.innerText = this.value || '';
+ }
+ }
+
+ private async updateHintInDomIfRendered() {
+ // Wait for editable div to render then process the hint.
+ await this.editableDiv;
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private async getValue() {
+ const editableDivElement = await this.editableDiv;
+ if (editableDivElement) {
+ const [output] = this.parseText(editableDivElement, false, true);
+ return output;
+ }
+ return '';
+ }
+
+ private parseText(
+ node: Node,
+ isLastBr: boolean,
+ isFirst: boolean
+ ): [string, boolean] {
+ let textValue = '';
+ let output = '';
+ if (node.nodeName === 'BR') {
+ return ['\n', true];
+ }
+
+ if (node.nodeType === Node.TEXT_NODE && node.textContent) {
+ return [node.textContent, false];
+ }
+
+ if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
+ textValue = '\n';
+ }
+
+ isLastBr = false;
+
+ for (let i = 0; i < node.childNodes?.length; i++) {
+ [output, isLastBr] = this.parseText(
+ node.childNodes[i],
+ isLastBr,
+ i === 0
+ );
+ textValue += output;
+ }
+ return [textValue, isLastBr];
+ }
+
+ private async getCursorPosition() {
+ const selection = this.getSelection();
+ const editableDivElement = await this.editableDiv;
+
+ // Cursor position is -1 (not available) if
+ //
+ // If textarea is not rendered.
+ // If textarea is not focused
+ // There is no accessible selection object.
+ // This is not a collapsed selection.
+ if (
+ !editableDivElement ||
+ !this.focused ||
+ !selection ||
+ selection.focusNode === null ||
+ !selection.isCollapsed
+ ) {
+ return -1;
+ }
+
+ let cursorPosition = 0;
+ let isOnFreshLine = true;
+
+ const findCursorPosition = (childNodes: Node[]) => {
+ for (let i = 0; i < childNodes.length; i++) {
+ const childNode = childNodes[i];
+
+ if (childNode.nodeName === 'BR') {
+ cursorPosition++;
+ isOnFreshLine = true;
+ continue;
+ }
+
+ if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+ cursorPosition++;
+ }
+
+ isOnFreshLine = false;
+
+ if (childNode === selection.focusNode) {
+ cursorPosition += selection.focusOffset;
+ break;
+ } else if (childNode.nodeType === 3 && childNode.textContent) {
+ cursorPosition += childNode.textContent.length;
+ }
+
+ if (childNode.childNodes?.length > 0) {
+ findCursorPosition(Array.from(childNode.childNodes));
+ }
+ }
+ };
+
+ if (editableDivElement === selection.focusNode) {
+ // If focus node is the top textarea then focusOffset is the number of
+ // child nodes before the cursor position.
+ const partOfNodes = Array.from(editableDivElement.childNodes).slice(
+ 0,
+ selection.focusOffset
+ );
+ findCursorPosition(partOfNodes);
+ } else {
+ findCursorPosition(Array.from(editableDivElement.childNodes));
+ }
+
+ return cursorPosition;
+ }
+
+ /** Gets the current selection, preferring the shadow DOM selection. */
+ private getSelection(): Selection | undefined | null {
+ // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
+ return this.shadowRoot?.getSelection?.();
+ }
+
+ private scrollToCursorPosition(range: Range) {
+ const tempAnchorEl = document.createElement('br');
+ range.insertNode(tempAnchorEl);
+
+ tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+
+ tempAnchorEl.remove();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-textarea': GrTextarea;
+ }
+ interface HTMLElementEventMap {
+ // prettier-ignore
+ 'saveShortcut': CustomEvent<{}>;
+ // prettier-ignore
+ 'hintApplied': CustomEvent<HintAppliedEventDetail>;
+ // prettier-ignore
+ 'hintShown': CustomEvent<HintShownEventDetail>;
+ // prettier-ignore
+ 'hintDismissed': CustomEvent<HintDismissedEventDetail>;
+ // prettier-ignore
+ 'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
+ }
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
new file mode 100644
index 0000000..7af3697
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitForEventOnce} from '../utils/event-util';
+import {
+ AUTOCOMPLETE_HINT_CLASS,
+ CursorPositionChangeEventDetail,
+ GrTextarea,
+} from './gr-textarea';
+
+async function rafPromise() {
+ return new Promise(res => {
+ requestAnimationFrame(res);
+ });
+}
+
+suite('gr-textarea test', () => {
+ let element: GrTextarea;
+
+ setup(async () => {
+ element = await fixture(html` <gr-textarea> </gr-textarea>`);
+ });
+
+ test('text area is registered correctly', () => {
+ assert.instanceOf(element, GrTextarea);
+ });
+
+ test('when disabled textarea have contenteditable set to false', async () => {
+ element.disabled = true;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.getAttribute('contenteditable'), 'false');
+ });
+
+ test('when disabled textarea have aria-disabled set', async () => {
+ element.disabled = true;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.isDefined(editableDiv?.getAttribute('aria-disabled'));
+ });
+
+ test('when textarea has placeholder, set aria-placeholder to placeholder text', async () => {
+ const placeholder = 'A sample placehodler...';
+ element.placeholder = placeholder;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.getAttribute('aria-placeholder'), placeholder);
+ });
+
+ test('renders the value', async () => {
+ const value = 'Some value';
+ element.value = value;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.innerText, value);
+ });
+
+ test('streams change event when editable div has input event', async () => {
+ const value = 'Some value \n other value';
+ const INPUT_EVENT = 'input';
+ let changeCalled = false;
+
+ element.addEventListener(INPUT_EVENT, () => {
+ changeCalled = true;
+ });
+
+ const changeEventPromise = waitForEventOnce(element, INPUT_EVENT);
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+
+ editableDiv.innerText = value;
+ editableDiv.dispatchEvent(new Event('input'));
+ await changeEventPromise;
+
+ assert.isTrue(changeCalled);
+ });
+
+ test('does not have focus by default', async () => {
+ assert.isFalse(element.isFocused);
+ });
+
+ test('when focused, isFocused is set to true', async () => {
+ await element.focus();
+ assert.isTrue(element.isFocused);
+ });
+
+ test('when cursor position is set to 0', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ await element.setCursorPosition(0);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 0);
+ });
+
+ test('when cursor position is set to 1', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ element.value = 'Some value';
+ await element.updateComplete;
+ await element.setCursorPosition(1);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 1);
+ });
+
+ test('when cursor position is set to new line', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ element.value = 'Some \n\n\n value';
+ await element.updateComplete;
+ await element.setCursorPosition(7);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 7);
+ });
+
+ test('when textarea is empty, placeholder hint is shown', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const placeholderHint = 'Some value';
+
+ element.placeholderHint = placeholderHint;
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.dataset['placeholder'], placeholderHint);
+ });
+
+ test('when TAB is pressed, placeholder hint is added as content', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const placeholderHint = 'Some value';
+
+ element.placeholderHint = placeholderHint;
+ await element.updateComplete;
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ await element.updateComplete;
+
+ assert.equal(element.value, placeholderHint);
+ });
+
+ test('when cursor is at end, hint is shown', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const oldValue = 'Hola';
+ const hint = 'amigos';
+
+ element.hint = hint;
+ await element.updateComplete;
+ element.value = oldValue;
+ await element.putCursorAtEnd();
+ await element.updateComplete;
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+ await element.updateComplete;
+ await rafPromise();
+
+ const spanHintElement = editableDiv?.querySelector(
+ '.' + AUTOCOMPLETE_HINT_CLASS
+ ) as HTMLSpanElement;
+ const styles = window.getComputedStyle(spanHintElement, ':before');
+ assert.equal(styles['content'], '"' + hint + '"');
+ });
+
+ test('when TAB is pressed, hint is added as content', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const oldValue = 'Hola';
+ const hint = 'amigos';
+
+ element.hint = hint;
+ element.value = oldValue;
+ await element.updateComplete;
+ await element.putCursorAtEnd();
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+ await rafPromise();
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ await element.updateComplete;
+
+ assert.equal(element.value, oldValue + hint);
+ });
+});
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 49b8edf..603e6c8 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-acceptance-framework</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Acceptance Test Framework</name>
<description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ac3dbac..241d59b 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-extension-api</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Extension API</name>
<description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index b3408d1..00132bd 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-plugin-api</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Plugin API</name>
<description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 2f72352..5a21ab7 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-war</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>Gerrit Code Review - WAR</name>
<description>Gerrit WAR</description>
diff --git a/version.bzl b/version.bzl
index 23ccefb..181159e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
# Used by :api_install and :api_deploy targets
# when talking to the destination repository.
#
-GERRIT_VERSION = "3.10.0-SNAPSHOT"
+GERRIT_VERSION = "3.11.0-SNAPSHOT"