Merge "CreateChange#CommitTreeSupplier: also pass the ObjectReader instance"
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 0da8aaa..a963edb 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1770,6 +1770,33 @@
)]}'
----
+[[cleanup.changes]]
+=== Cleanup of stale changes
+
+This endpoint allows Gerrit administrators to abandon changes older than some given
+time. This allows to run change cleanup manually outside of the configured schedule or
+if change cleanup has been deactivated.
+
+The change cleanup will run asynchronously.
+
+.Request
+----
+ POST /config/server/cleanup.changes HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "after": "3 months",
+ "if_mergeable": true,
+ "message": "Abandoning stale changes."
+ }
+----
+
+.Response
+----
+ HTTP/1.1 202 Accepted
+ Content-Disposition: attachment
+----
+
[[experiment-endpoints]]
== Experiment Endpoints
@@ -2637,6 +2664,22 @@
name of the user is not set.
|====================================
+[[clean-changes-input]]
+=== CleanChanges.Input
+The `CleanChanges.Input` entity is being used to configure a run
+of change cleanup.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name||Description
+|`after`||Abandon all changes that weren't updated in the
+timespan given here
+|`if_mergeable`|default: `false`|Whether to also abandon changes
+that are mergeable
+|`message`|optional|Message to post to changes abandoned by the
+cleanup
+|=============================
+
GERRIT
------
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 07280ba..1a2c63d 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -23,7 +23,6 @@
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.ChangeCleanupConfig;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeQueryProcessor;
@@ -40,7 +39,6 @@
public class AbandonUtil {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private final ChangeCleanupConfig cfg;
private final Provider<ChangeQueryProcessor> queryProvider;
private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
private final BatchAbandon batchAbandon;
@@ -48,27 +46,27 @@
@Inject
AbandonUtil(
- ChangeCleanupConfig cfg,
InternalUser.Factory internalUserFactory,
Provider<ChangeQueryProcessor> queryProvider,
Provider<ChangeQueryBuilder> queryBuilderProvider,
BatchAbandon batchAbandon) {
- this.cfg = cfg;
this.queryProvider = queryProvider;
this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
this.batchAbandon = batchAbandon;
internalUser = internalUserFactory.create();
}
- public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
- if (cfg.getAbandonAfter() <= 0) {
+ public void abandonInactiveOpenChanges(
+ BatchUpdate.Factory updateFactory,
+ long abandonAfterMillis,
+ boolean abandonIfMergeable,
+ String message) {
+ if (abandonAfterMillis <= 0) {
return;
}
-
try {
- String query =
- "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter()) + "m";
- if (!cfg.getAbandonIfMergeable()) {
+ String query = "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(abandonAfterMillis) + "m";
+ if (!abandonIfMergeable) {
query += " -is:mergeable";
}
@@ -86,7 +84,6 @@
int count = 0;
ImmutableListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
- String message = cfg.getAbandonMessage();
for (Project.NameKey project : abandons.keySet()) {
List<ChangeData> changes = getValidChanges(abandons.get(project), query);
try {
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 2f2cff9..728830c 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.change;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.lifecycle.LifecycleModule;
@@ -25,6 +26,8 @@
import com.google.gerrit.server.util.ManualRequestContext;
import com.google.gerrit.server.util.OneOffRequestContext;
import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
/** Runnable to enable scheduling change cleanups to run periodically */
public class ChangeCleanupRunner implements Runnable {
@@ -34,18 +37,25 @@
@Override
protected void configure() {
listener().to(Lifecycle.class);
+ factory(Factory.class);
}
}
+ public interface Factory {
+ ChangeCleanupRunner create();
+
+ ChangeCleanupRunner create(long abandonAfterMillis, boolean abandonIfMergeable, String message);
+ }
+
static class Lifecycle implements LifecycleListener {
private final WorkQueue queue;
private final ChangeCleanupRunner runner;
private final ChangeCleanupConfig cfg;
@Inject
- Lifecycle(WorkQueue queue, ChangeCleanupRunner runner, ChangeCleanupConfig cfg) {
+ Lifecycle(WorkQueue queue, ChangeCleanupRunner.Factory runner, ChangeCleanupConfig cfg) {
this.queue = queue;
- this.runner = runner;
+ this.runner = runner.create();
this.cfg = cfg;
}
@@ -63,13 +73,38 @@
private final OneOffRequestContext oneOffRequestContext;
private final AbandonUtil abandonUtil;
private final RetryHelper retryHelper;
+ private final long abandonAfterMillis;
+ private final boolean abandonIfMergeable;
+ @Nullable private final String message;
- @Inject
+ @AssistedInject
ChangeCleanupRunner(
- OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper) {
+ OneOffRequestContext oneOffRequestContext,
+ AbandonUtil abandonUtil,
+ RetryHelper retryHelper,
+ @Assisted long abandonAfterMillis,
+ @Assisted boolean abandonIfMergeable,
+ @Assisted @Nullable String message) {
this.oneOffRequestContext = oneOffRequestContext;
this.abandonUtil = abandonUtil;
this.retryHelper = retryHelper;
+ this.abandonAfterMillis = abandonAfterMillis;
+ this.abandonIfMergeable = abandonIfMergeable;
+ this.message = message;
+ }
+
+ @AssistedInject
+ ChangeCleanupRunner(
+ OneOffRequestContext oneOffRequestContext,
+ AbandonUtil abandonUtil,
+ RetryHelper retryHelper,
+ ChangeCleanupConfig cfg) {
+ this.oneOffRequestContext = oneOffRequestContext;
+ this.abandonUtil = abandonUtil;
+ this.retryHelper = retryHelper;
+ this.abandonAfterMillis = cfg.getAbandonAfter();
+ this.abandonIfMergeable = cfg.getAbandonIfMergeable();
+ this.message = cfg.getAbandonMessage();
}
@Override
@@ -85,7 +120,8 @@
.changeUpdate(
"abandonInactiveOpenChanges",
updateFactory -> {
- abandonUtil.abandonInactiveOpenChanges(updateFactory);
+ abandonUtil.abandonInactiveOpenChanges(
+ updateFactory, abandonAfterMillis, abandonIfMergeable, message);
return null;
})
.call();
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index b3ce564..ae80fe4 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -42,40 +42,49 @@
private final ObjectId baseTreeId;
private final ImmutableList<? extends ObjectId> baseParents;
private final Optional<ObjectInserter> objectInserter;
+ private final Optional<ObjectReader> objectReader;
private final List<TreeModification> treeModifications = new ArrayList<>();
public static TreeCreator basedOn(RevCommit baseCommit) {
requireNonNull(baseCommit, "baseCommit is required");
return new TreeCreator(
- baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()), Optional.empty());
+ baseCommit.getTree(),
+ ImmutableList.copyOf(baseCommit.getParents()),
+ Optional.empty(),
+ Optional.empty());
}
@UsedAt(UsedAt.Project.GOOGLE)
- public static TreeCreator basedOn(RevCommit baseCommit, ObjectInserter objectInserter) {
+ public static TreeCreator basedOn(
+ RevCommit baseCommit, ObjectInserter objectInserter, ObjectReader objectReader) {
requireNonNull(baseCommit, "baseCommit is required");
return new TreeCreator(
baseCommit.getTree(),
ImmutableList.copyOf(baseCommit.getParents()),
- Optional.of(objectInserter));
+ Optional.of(objectInserter),
+ Optional.of(objectReader));
}
public static TreeCreator basedOnTree(
ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
requireNonNull(baseTreeId, "baseTreeId is required");
- return new TreeCreator(baseTreeId, baseParents, Optional.empty());
+ return new TreeCreator(baseTreeId, baseParents, Optional.empty(), Optional.empty());
}
public static TreeCreator basedOnEmptyTree() {
- return new TreeCreator(ObjectId.zeroId(), ImmutableList.of(), Optional.empty());
+ return new TreeCreator(
+ ObjectId.zeroId(), ImmutableList.of(), Optional.empty(), Optional.empty());
}
private TreeCreator(
ObjectId baseTreeId,
ImmutableList<? extends ObjectId> baseParents,
- Optional<ObjectInserter> objectInserter) {
+ Optional<ObjectInserter> objectInserter,
+ Optional<ObjectReader> objectReader) {
this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
this.baseParents = baseParents;
this.objectInserter = objectInserter;
+ this.objectReader = objectReader;
}
/**
@@ -138,14 +147,22 @@
}
private DirCache readBaseTree(Repository repository) throws IOException {
- try (ObjectReader objectReader = repository.newObjectReader()) {
- DirCache dirCache = DirCache.newInCore();
+ ObjectReader or = objectReader.orElseGet(() -> repository.newObjectReader());
+ try {
+ DirCache dirCache =
+ ObjectId.zeroId().equals(baseTreeId)
+ ? DirCache.newInCore()
+ : DirCache.read(or, baseTreeId);
DirCacheBuilder dirCacheBuilder = dirCache.builder();
if (!ObjectId.zeroId().equals(baseTreeId)) {
- dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+ dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, or, baseTreeId);
}
dirCacheBuilder.finish();
return dirCache;
+ } finally {
+ if (objectReader.isEmpty()) {
+ or.close();
+ }
}
}
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index 73991c9..359393e 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
import com.google.gerrit.server.plugins.PluginRestApiModule;
import com.google.gerrit.server.restapi.access.AccessRestApiModule;
import com.google.gerrit.server.restapi.account.AccountRestApiModule;
@@ -42,5 +43,6 @@
install(new PluginRestApiModule());
install(new ProjectRestApiModule());
install(new ProjectRestApiModule.BatchModule());
+ install(new ChangeCleanupRunnerModule());
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 7d8c793..41faf22 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -16,62 +16,43 @@
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project.NameKey;
import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.git.CommitUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ContributorAgreementsChecker;
import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.time.Instant;
-import java.time.ZoneId;
import java.util.List;
-import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.patch.PatchApplier;
@@ -80,53 +61,42 @@
@Singleton
public class ApplyPatch implements RestModifyView<ChangeResource, ApplyPatchPatchSetInput> {
- private final ChangeJson.Factory jsonFactory;
+
private final ContributorAgreementsChecker contributorAgreements;
- private final Provider<IdentifiedUser> user;
private final GitRepositoryManager gitManager;
- private final BatchUpdate.Factory batchUpdateFactory;
- private final PatchSetInserter.Factory patchSetInserterFactory;
private final Provider<InternalChangeQuery> queryProvider;
- private final ZoneId serverZoneId;
private final ProjectCache projectCache;
private final ChangeUtil changeUtil;
+ private final PatchSetCreator patchSetCreator;
@Inject
ApplyPatch(
- ChangeJson.Factory jsonFactory,
ContributorAgreementsChecker contributorAgreements,
- Provider<IdentifiedUser> user,
GitRepositoryManager gitManager,
- BatchUpdate.Factory batchUpdateFactory,
- PatchSetInserter.Factory patchSetInserterFactory,
Provider<InternalChangeQuery> queryProvider,
- @GerritPersonIdent PersonIdent myIdent,
ProjectCache projectCache,
- ChangeUtil changeUtil) {
- this.jsonFactory = jsonFactory;
+ ChangeUtil changeUtil,
+ PatchSetCreator patchSetCreator) {
this.contributorAgreements = contributorAgreements;
- this.user = user;
this.gitManager = gitManager;
- this.batchUpdateFactory = batchUpdateFactory;
- this.patchSetInserterFactory = patchSetInserterFactory;
this.queryProvider = queryProvider;
- this.serverZoneId = myIdent.getZoneId();
this.projectCache = projectCache;
this.changeUtil = changeUtil;
+ this.patchSetCreator = patchSetCreator;
}
@Override
public Response<ChangeInfo> apply(ChangeResource rsrc, ApplyPatchPatchSetInput input)
throws IOException, UpdateException, RestApiException, PermissionBackendException,
ConfigInvalidException, NoSuchProjectException, InvalidChangeOperationException {
- NameKey project = rsrc.getProject();
- contributorAgreements.check(project, rsrc.getUser());
- BranchNameKey destBranch = rsrc.getChange().getDest();
-
if (input == null || input.patch == null || input.patch.patch == null) {
throw new BadRequestException("patch required");
}
+ NameKey project = rsrc.getProject();
+ contributorAgreements.check(project, rsrc.getUser());
+ BranchNameKey destBranch = rsrc.getChange().getDest();
+
try (Repository repo = gitManager.openRepository(project);
// This inserter and revwalk *must* be passed to any BatchUpdates
// created later on, to ensure the applied commit is flushed
@@ -140,22 +110,7 @@
String.format("Branch %s does not exist.", destBranch.branch()));
}
ChangeData destChange = rsrc.getChangeData();
- if (destChange == null) {
- throw new PreconditionFailedException(
- "patch:apply cannot be called without a destination change.");
- }
-
- if (destChange.change().isClosed()) {
- throw new PreconditionFailedException(
- String.format(
- "patch:apply with Change-Id %s could not update the existing change %d "
- + "in destination branch %s of project %s, because the change was closed (%s)",
- destChange.getId(),
- destChange.getId().get(),
- destBranch.branch(),
- destBranch.project(),
- destChange.change().getStatus().name()));
- }
+ patchSetCreator.validateChangeCanBeAppended(destChange, destBranch);
if (!Strings.isNullOrEmpty(input.base) && Boolean.TRUE.equals(input.amend)) {
throw new BadRequestException("amend only works with existing revisions. omit base.");
@@ -173,7 +128,8 @@
if (latestPatchset.getParentCount() != 1) {
throw new BadRequestException(
String.format(
- "Cannot parse base commit for a change with none or multiple parents. Change ID: %s.",
+ "Cannot parse base commit for a change with none or multiple parents. Change ID:"
+ + " %s.",
destChange.getId()));
}
if (Boolean.TRUE.equals(input.amend)) {
@@ -184,50 +140,37 @@
parents = ImmutableList.of(baseCommit);
}
}
+
+ List<ListChangesOption> opts = input.responseFormatOptions;
+ if (opts == null) {
+ opts = ImmutableList.of();
+ }
+
PatchApplier.Result applyResult =
ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
- ObjectId treeId = applyResult.getTreeId();
- Instant now = TimeUtil.now();
- PersonIdent committerIdent =
- Optional.ofNullable(latestPatchset.getCommitterIdent())
- .map(
- ident ->
- user.get()
- .newCommitterIdent(ident.getEmailAddress(), now, serverZoneId)
- .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId)))
- .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId));
- PersonIdent authorIdent =
- input.author == null
- ? committerIdent
- : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
String commitMessage =
buildFullCommitMessage(
project,
latestPatchset,
input,
- ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
+ ApplyPatchUtil.getResultPatch(
+ repo, reader, baseCommit, revWalk.lookupTree(applyResult.getTreeId())),
applyResult.getErrors());
- ObjectId appliedCommit =
- CommitUtil.createCommitWithTree(
- oi, authorIdent, committerIdent, parents, commitMessage, treeId);
- CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
- oi.flush();
-
- Change resultChange;
- try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
- bu.setRepository(repo, revWalk, oi);
- resultChange =
- insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
- } catch (NoSuchChangeException | RepositoryNotFoundException e) {
- throw new ResourceConflictException(e.getMessage());
- }
- List<ListChangesOption> opts = input.responseFormatOptions;
- if (opts == null) {
- opts = ImmutableList.of();
- }
- ChangeInfo changeInfo = jsonFactory.create(opts).format(resultChange);
+ ChangeInfo changeInfo =
+ patchSetCreator.createPatchSetWithSuppliedTree(
+ project,
+ destChange,
+ latestPatchset,
+ parents,
+ input.author,
+ opts,
+ repo,
+ oi,
+ revWalk,
+ applyResult.getTreeId(),
+ commitMessage);
return Response.ok(changeInfo);
}
}
@@ -275,28 +218,6 @@
return commitMessage;
}
- private static Change insertPatchSet(
- BatchUpdate bu,
- Repository git,
- PatchSetInserter.Factory patchSetInserterFactory,
- ChangeNotes destNotes,
- CodeReviewCommit commit)
- throws IOException, UpdateException, RestApiException {
- try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
- Change destChange = destNotes.getChange();
- PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
- PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
- inserter.setMessage(buildMessageForPatchSet(psId));
- bu.addOp(destChange.getId(), inserter);
- bu.execute();
- return inserter.getChange();
- }
- }
-
- private static String buildMessageForPatchSet(PatchSet.Id psId) {
- return new StringBuilder(String.format("Uploaded patch set %s.", psId.get())).toString();
- }
-
private String removeFooters(String originalMessage, List<FooterLine> footerLines) {
if (footerLines.isEmpty()) {
return originalMessage;
diff --git a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
new file mode 100644
index 0000000..60c8e89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2024 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.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Optional;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** A utility class for creating a patch set on an existing change. */
+@Singleton
+public class PatchSetCreator {
+ private final Provider<IdentifiedUser> ident;
+ private final BatchUpdate.Factory batchUpdateFactory;
+ private final PatchSetInserter.Factory patchSetInserterFactory;
+ private final ChangeJson.Factory jsonFactory;
+ private final ZoneId serverZoneId;
+
+ @Inject
+ PatchSetCreator(
+ Provider<IdentifiedUser> ident,
+ BatchUpdate.Factory batchUpdateFactory,
+ PatchSetInserter.Factory patchSetInserterFactory,
+ @GerritPersonIdent PersonIdent myIdent,
+ ChangeJson.Factory jsonFactory) {
+ this.ident = ident;
+ this.batchUpdateFactory = batchUpdateFactory;
+ this.patchSetInserterFactory = patchSetInserterFactory;
+ this.serverZoneId = myIdent.getZoneId();
+ this.jsonFactory = jsonFactory;
+ }
+
+ public ChangeInfo createPatchSetWithSuppliedTree(
+ Project.NameKey project,
+ ChangeData destChange,
+ RevCommit latestPatchset,
+ List<RevCommit> parents,
+ @Nullable AccountInput author,
+ List<ListChangesOption> outputOptions,
+ Repository repo,
+ ObjectInserter oi,
+ CodeReviewRevWalk revWalk,
+ ObjectId commitTree,
+ String commitMessage)
+ throws IOException, RestApiException, UpdateException {
+ requireNonNull(destChange);
+ requireNonNull(latestPatchset);
+ requireNonNull(parents);
+ requireNonNull(outputOptions);
+
+ Instant now = TimeUtil.now();
+ PersonIdent committerIdent =
+ Optional.ofNullable(latestPatchset.getCommitterIdent())
+ .map(
+ id ->
+ ident
+ .get()
+ .newCommitterIdent(id.getEmailAddress(), now, serverZoneId)
+ .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId)))
+ .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId));
+ PersonIdent authorIdent =
+ author == null
+ ? committerIdent
+ : new PersonIdent(author.name, author.email, now, serverZoneId);
+
+ ObjectId appliedCommit =
+ CommitUtil.createCommitWithTree(
+ oi, authorIdent, committerIdent, parents, commitMessage, commitTree);
+ CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
+ oi.flush();
+
+ Change resultChange;
+ try (BatchUpdate bu = batchUpdateFactory.create(project, ident.get(), TimeUtil.now())) {
+ bu.setRepository(repo, revWalk, oi);
+ resultChange = insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
+ } catch (NoSuchChangeException | RepositoryNotFoundException e) {
+ throw new ResourceConflictException(e.getMessage());
+ }
+
+ return jsonFactory.create(outputOptions).format(resultChange);
+ }
+
+ public void validateChangeCanBeAppended(@Nullable ChangeData destChange, BranchNameKey destBranch)
+ throws PreconditionFailedException {
+ if (destChange == null) {
+ throw new PreconditionFailedException(
+ "cannot write a patch set without a destination change.");
+ }
+
+ if (destChange.change().isClosed()) {
+ throw new PreconditionFailedException(
+ String.format(
+ "patch:apply with Change-Id %s could not update the existing change %d "
+ + "in destination branch %s of project %s, because the change was closed (%s)",
+ destChange.getId(),
+ destChange.getId().get(),
+ destBranch.branch(),
+ destBranch.project(),
+ destChange.change().getStatus().name()));
+ }
+ }
+
+ private static Change insertPatchSet(
+ BatchUpdate bu,
+ Repository git,
+ PatchSetInserter.Factory patchSetInserterFactory,
+ ChangeNotes destNotes,
+ CodeReviewCommit commit)
+ throws IOException, UpdateException, RestApiException {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ Change destChange = destNotes.getChange();
+ PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+ PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
+ inserter.setMessage(buildMessageForPatchSet(psId));
+ bu.addOp(destChange.getId(), inserter);
+ bu.execute();
+ return inserter.getChange();
+ }
+ }
+
+ private static String buildMessageForPatchSet(PatchSet.Id psId) {
+ return String.format("Uploaded patch set %s.", psId.get());
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/CleanupChanges.java b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
new file mode 100644
index 0000000..9ae3637
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2024 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.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.restapi.config.CleanupChanges.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+@Singleton
+public class CleanupChanges implements RestModifyView<ConfigResource, Input> {
+ private final ChangeCleanupRunner.Factory runnerFactory;
+ private final WorkQueue workQueue;
+
+ public static class Input {
+ String after;
+ boolean ifMergeable;
+ String message;
+ }
+
+ @Inject
+ CleanupChanges(WorkQueue workQueue, ChangeCleanupRunner.Factory runnerFactory) {
+ this.runnerFactory = runnerFactory;
+ this.workQueue = workQueue;
+ }
+
+ @Override
+ public Response<?> apply(ConfigResource rsrc, Input input) throws BadRequestException {
+ if (taskAlreadyScheduled()) {
+ return Response.ok("Change cleaner already in queue.");
+ }
+ if (input.after == null) {
+ throw new BadRequestException("`after` must be specified.");
+ }
+ ChangeCleanupRunner runner =
+ runnerFactory.create(
+ ConfigUtil.getTimeUnit(input.after, 0, TimeUnit.MILLISECONDS),
+ input.ifMergeable,
+ input.message);
+ @SuppressWarnings("unused")
+ Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(() -> runner.run());
+ return Response.accepted("Change cleaner task added to work queue.");
+ }
+
+ private boolean taskAlreadyScheduled() {
+ for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+ if (task.toString().contains(ChangeCleanupRunner.class.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index f42cb50..2f21da6 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -54,6 +54,7 @@
put(CONFIG_KIND, "preferences.edit").to(SetEditPreferences.class);
post(CONFIG_KIND, "reload").to(ReloadConfig.class);
post(CONFIG_KIND, "snapshot.indexes").to(SnapshotIndexes.class);
+ post(CONFIG_KIND, "cleanup.changes").to(CleanupChanges.class);
child(CONFIG_KIND, "tasks").to(TasksCollection.class);
delete(TASK_KIND).to(DeleteTask.class);
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b0045e3..8e065b9 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -57,6 +57,7 @@
import com.google.gerrit.server.audit.AuditModule;
import com.google.gerrit.server.cache.h2.H2CacheModule;
import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
import com.google.gerrit.server.change.FileInfoJsonModule;
import com.google.gerrit.server.config.AllProjectsConfigProvider;
import com.google.gerrit.server.config.AllProjectsName;
@@ -209,6 +210,7 @@
install(new RepoSequenceModule());
install(new NoteDbDraftCommentsModule());
install(new NoteDbStarredChangesModule());
+ install(new ChangeCleanupRunnerModule());
AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
install(new AuthModule(authConfig));
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index f0f262f..18969ad 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -40,7 +40,7 @@
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
import com.google.gerrit.server.config.ChangeCleanupConfig;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.testing.TestTimeUtil;
@@ -53,7 +53,7 @@
import org.junit.Test;
public class AbandonIT extends AbstractDaemonTest {
- @Inject private AbandonUtil abandonUtil;
+ @Inject private ChangeCleanupRunner.Factory cleanupRunner;
@Inject private ChangeCleanupConfig cleanupConfig;
@Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
@@ -140,7 +140,7 @@
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
assertThat(query("is:abandoned")).isEmpty();
- abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+ cleanupRunner.create().run();
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
}
@@ -182,7 +182,7 @@
assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
assertThat(toChangeNumbers(query("-is:mergeable"))).containsExactly(id4);
- abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+ cleanupRunner.create().run();
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5, id2, id1);
assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4);
}
@@ -229,7 +229,7 @@
assertThrows(BadRequestException.class, () -> query("-is:mergeable"));
assertThat(thrown).hasMessageThat().contains("operator is not supported");
- abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+ cleanupRunner.create().run();
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5);
assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4, id2, id1);
}
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 62a095f..6cfb989 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -1371,8 +1371,21 @@
@Test
public void canCombineEdits() throws Exception {
- createEmptyEditFor(changeId);
+ String baseFileToDelete = "base_file_to_delete";
+ String baseFileToRename = "base_file_to_rename";
+ String baseFileNewName = "base_file_new_name";
+ String currPatchSetFileToRename = "current_patchset_file_to_rename";
+ String currPatchSetFileNewName = "current_patchset_file_new_name";
+ // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
+ testRepo = cloneProject(project);
+ String baseChangeId = newChangeWithFile(admin.newIdent(), baseFileToDelete, "content");
+ addNewPatchSetWithModifiedFile(baseChangeId, baseFileToRename, "content2");
+ gApi.changes().id(baseChangeId).current().review(ReviewInput.approve());
+ gApi.changes().id(baseChangeId).current().submit();
+ String changeId = newChange(admin.newIdent());
+ addNewPatchSetWithModifiedFile(changeId, currPatchSetFileToRename, "content3");
+ createEmptyEditFor(changeId);
// update author
gApi.changes()
.id(changeId)
@@ -1394,11 +1407,20 @@
.modifyIdentity(
"Test Committer", "test.committer@example.com", ChangeEditIdentityType.COMMITTER);
- // delete file
+ // delete current patch-set file
gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
- // rename file
- gApi.changes().id(changeId).edit().renameFile(FILE_NAME2, FILE_NAME3);
+ // delete base file
+ gApi.changes().id(changeId).edit().deleteFile(baseFileToDelete);
+
+ // rename current patch-set file
+ gApi.changes()
+ .id(changeId)
+ .edit()
+ .renameFile(currPatchSetFileToRename, currPatchSetFileNewName);
+
+ // rename base file
+ gApi.changes().id(changeId).edit().renameFile(baseFileToRename, baseFileNewName);
// publish edit
PublishChangeEditInput publishInput = new PublishChangeEditInput();
@@ -1419,7 +1441,12 @@
assertThat(currentCommit.author.name).isEqualTo("Test Author");
assertThat(currentCommit.author.email).isEqualTo("test.author@example.com");
assertThat(currentCommit.message).isEqualTo(msg);
- assertThat(currentRevision.files.keySet()).containsExactly(newFile, FILE_NAME3);
+ assertThat(currentRevision.files.keySet())
+ .containsExactly(newFile, baseFileToDelete, baseFileNewName, currPatchSetFileNewName);
+ assertThat(currentRevision.files.get(newFile).status).isEqualTo('A');
+ assertThat(currentRevision.files.get(baseFileToDelete).status).isEqualTo('D');
+ assertThat(currentRevision.files.get(baseFileNewName).status).isEqualTo('R');
+ assertThat(currentRevision.files.get(currPatchSetFileNewName).status).isEqualTo('A');
}
private void createArbitraryEditFor(String changeId) throws Exception {
@@ -1451,6 +1478,13 @@
return push.to("refs/for/master").getChangeId();
}
+ private String newChangeWithFile(PersonIdent ident, String filePath, String fileContent)
+ throws Exception {
+ PushOneCommit push =
+ pushFactory.create(ident, testRepo, PushOneCommit.SUBJECT, filePath, fileContent);
+ return push.to("refs/for/master").getChangeId();
+ }
+
private void addNewPatchSet(String changeId) throws Exception {
addNewPatchSetWithModifiedFile(changeId, FILE_NAME2, new String(CONTENT_NEW2, UTF_8));
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 9f2401a..bfc64a6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -66,7 +66,8 @@
RestCall.post("/config/server/deactivate.stale.accounts"),
RestCall.get("/config/server/tasks"),
RestCall.get("/config/server/top-menus"),
- RestCall.get("/config/server/version"));
+ RestCall.get("/config/server/version"),
+ RestCall.post("/config/server/cleanup.changes"));
/**
* Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index 2916f75..e63ac8f 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -7,12 +7,14 @@
import '../../shared/gr-avatar/gr-avatar';
import {getUserName} from '../../../utils/display-name-util';
import {AccountInfo, DropdownLink, ServerInfo} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
import {fire} from '../../../utils/event-util';
import {DropdownContent} from '../../shared/gr-dropdown/gr-dropdown';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {customElement, property, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {subscribe} from '../../lit/subscription-controller';
const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
@@ -30,34 +32,44 @@
@property({type: Object})
account?: AccountInfo;
- @property({type: Object})
+ // Private but used in test
+ @state()
config?: ServerInfo;
- @property({type: String})
- _path = '/';
+ @state()
+ private path = '/';
- @property({type: Boolean})
- _hasAvatars = false;
+ @state()
+ private hasAvatars = false;
- @property({type: String})
- _switchAccountUrl = '';
+ @state()
+ private switchAccountUrl = '';
- private readonly restApiService = getAppContext().restApiService;
+ // Private but used in test
+ readonly getConfigModel = resolve(this, configModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ cfg => {
+ this.config = cfg;
+
+ if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+ this.switchAccountUrl = cfg.auth.switch_account_url;
+ } else {
+ this.switchAccountUrl = '';
+ }
+ this.hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+ }
+ );
+ }
override connectedCallback() {
super.connectedCallback();
this.handleLocationChange();
document.addEventListener('location-change', this.handleLocationChange);
- this.restApiService.getConfig().then(cfg => {
- this.config = cfg;
-
- if (cfg && cfg.auth && cfg.auth.switch_account_url) {
- this._switchAccountUrl = cfg.auth.switch_account_url;
- } else {
- this._switchAccountUrl = '';
- }
- this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
- });
}
override disconnectedCallback() {
@@ -88,15 +100,13 @@
link=""
.items=${this.links}
.topContent=${this.topContent}
- @tap-item-shortcuts=${this._handleShortcutsTap}
+ @tap-item-shortcuts=${this.handleShortcutsTap}
.horizontalAlign=${'right'}
>
- <span ?hidden=${this._hasAvatars}
- >${this._accountName(this.account)}</span
- >
+ <span ?hidden=${this.hasAvatars}>${this.accountName(this.account)}</span>
<gr-avatar
.account=${this.account}
- ?hidden=${!this._hasAvatars}
+ ?hidden=${!this.hasAvatars}
.imageSize=${56}
aria-label="Account avatar"
></gr-avatar>
@@ -104,14 +114,15 @@
}
get links(): DropdownLink[] | undefined {
- return this._getLinks(this._switchAccountUrl, this._path);
+ return this.getLinks(this.switchAccountUrl, this.path);
}
get topContent(): DropdownContent[] | undefined {
- return this._getTopContent(this.account);
+ return this.getTopContent(this.account);
}
- _getLinks(switchAccountUrl?: string, path?: string) {
+ // Private but used in test
+ getLinks(switchAccountUrl?: string, path?: string) {
if (switchAccountUrl === undefined || path === undefined) {
return undefined;
}
@@ -121,37 +132,39 @@
links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
if (switchAccountUrl) {
const replacements = {path};
- const url = this._interpolateUrl(switchAccountUrl, replacements);
+ const url = this.interpolateUrl(switchAccountUrl, replacements);
links.push({name: 'Switch account', url, external: true});
}
links.push({name: 'Sign out', id: 'signout', url: '/logout'});
return links;
}
- _getTopContent(account?: AccountInfo) {
+ // Private but used in test
+ getTopContent(account?: AccountInfo) {
return [
- {text: this._accountName(account), bold: true},
+ {text: this.accountName(account), bold: true},
{text: account?.email ? account.email : ''},
] as DropdownContent[];
}
- _handleShortcutsTap() {
+ private handleShortcutsTap() {
fire(this, 'show-keyboard-shortcuts', {});
}
private readonly handleLocationChange = () => {
- this._path =
+ this.path =
window.location.pathname + window.location.search + window.location.hash;
};
- _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+ // Private but used in test
+ interpolateUrl(url: string, replacements: {[key: string]: string}) {
return url.replace(
INTERPOLATE_URL_PATTERN,
(_, p1) => replacements[p1] || ''
);
}
- _accountName(account?: AccountInfo) {
+ private accountName(account?: AccountInfo) {
return getUserName(this.config, account);
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
index c224a6b..06dc3f8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -78,14 +78,14 @@
test('switch account', () => {
// Missing params.
- assert.isUndefined(element._getLinks());
- assert.isUndefined(element._getLinks(undefined));
+ assert.isUndefined(element.getLinks());
+ assert.isUndefined(element.getLinks(undefined));
// No switch account link.
- assert.equal(element._getLinks('', '')!.length, 3);
+ assert.equal(element.getLinks('', '')!.length, 3);
// Unparameterized switch account link.
- let links = element._getLinks('/switch-account', '')!;
+ let links = element.getLinks('/switch-account', '')!;
assert.equal(links.length, 4);
assert.deepEqual(links[2], {
name: 'Switch account',
@@ -94,7 +94,7 @@
});
// Parameterized switch account link.
- links = element._getLinks('/switch-account${path}', '/c/123')!;
+ links = element.getLinks('/switch-account${path}', '/c/123')!;
assert.equal(links.length, 4);
assert.deepEqual(links[2], {
name: 'Switch account',
@@ -103,13 +103,13 @@
});
});
- test('_interpolateUrl', () => {
+ test('interpolateUrl', () => {
const replacements = {
foo: 'bar',
test: 'TEST',
};
const interpolate = (url: string) =>
- element._interpolateUrl(url, replacements);
+ element.interpolateUrl(url, replacements);
assert.equal(interpolate('test'), 'test');
assert.equal(interpolate('${test}'), 'TEST');
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 15cfda6..cd19872 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -48,7 +48,7 @@
setup(async () => {
appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
- stubElement('gr-account-dropdown', '_getTopContent');
+ stubElement('gr-account-dropdown', 'getTopContent');
routerStartStub = sinon.stub(GrRouter.prototype, 'start');
stubRestApi('getAccount').returns(Promise.resolve(undefined));
stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 985f9be..0b5f952 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -9,8 +9,12 @@
import {grFormStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
import {modalStyles} from '../../../styles/gr-modal-styles';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
declare global {
interface HTMLElementTagNameMap {
@@ -23,47 +27,48 @@
@query('#generatedPasswordModal')
generatedPasswordModal?: HTMLDialogElement;
- @property({type: String})
+ @state()
username?: string;
- @property({type: String})
+ @state()
generatedPassword?: string;
- @property({type: String})
+ @state()
status?: string;
- @property({type: String})
+ @state()
passwordUrl: string | null = null;
private readonly restApiService = getAppContext().restApiService;
- override connectedCallback() {
- super.connectedCallback();
- this.loadData();
- }
+ // Private but used in test
+ readonly getConfigModel = resolve(this, configModelToken);
- loadData() {
- const promises = [];
+ // Private but used in test
+ readonly getUserModel = resolve(this, userModelToken);
- promises.push(
- this.restApiService.getAccount().then(account => {
- if (account) {
- this.username = account.username;
- }
- })
- );
-
- promises.push(
- this.restApiService.getConfig().then(info => {
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ info => {
if (info) {
this.passwordUrl = info.auth.http_password_url || null;
} else {
this.passwordUrl = null;
}
- })
+ }
);
-
- return Promise.all(promises);
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ account => {
+ if (account) {
+ this.username = account.username;
+ }
+ }
+ );
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index 1abf8a7..d94638b 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -14,7 +14,7 @@
import {AccountDetailInfo, ServerInfo} from '../../../types/common';
import {queryAndAssert} from '../../../test/test-utils';
import {GrButton} from '../../shared/gr-button/gr-button';
-import {fixture, html, assert} from '@open-wc/testing';
+import {fixture, html, assert, waitUntil} from '@open-wc/testing';
suite('gr-http-password tests', () => {
let element: GrHttpPassword;
@@ -29,7 +29,12 @@
stubRestApi('getConfig').returns(Promise.resolve(config));
element = await fixture(html`<gr-http-password></gr-http-password>`);
- await element.loadData();
+ await waitUntil(
+ () => element.getUserModel().getState().account === account
+ );
+ await waitUntil(
+ () => element.getConfigModel().getState().serverConfig === config
+ );
await waitEventLoop();
});
@@ -121,7 +126,8 @@
test('with http_password_url', async () => {
config.auth.http_password_url = 'http://example.com/';
- await element.loadData();
+ element.passwordUrl = config.auth.http_password_url;
+ await element.updateComplete;
assert.isNotNull(element.passwordUrl);
assert.equal(element.passwordUrl, config.auth.http_password_url);
});
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
new file mode 100644
index 0000000..bb0f1e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -0,0 +1,598 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@polymer/iron-input/iron-input';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-select/gr-select';
+import {AccountDetailInfo, PreferencesInput} from '../../../types/common';
+import {grFormStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {convertToString} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+import {
+ AppTheme,
+ DateFormat,
+ DiffViewMode,
+ EmailFormat,
+ EmailStrategy,
+ TimeFormat,
+} from '../../../constants/constants';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {areNotificationsEnabled} from '../../../utils/worker-util';
+import {getDocUrl} from '../../../utils/url-util';
+import {configModelToken} from '../../../models/config/config-model';
+import {SuggestionsProvider} from '../../../api/suggestions';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
+/**
+ * This provides an interface to show settings for a user profile
+ * as defined in PreferencesInfo.
+ */
+@customElement('gr-preferences')
+export class GrPreferences extends LitElement {
+ @query('#themeSelect') themeSelect!: HTMLInputElement;
+
+ @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
+
+ @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
+
+ @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
+
+ @query('#emailNotificationsSelect')
+ emailNotificationsSelect!: HTMLInputElement;
+
+ @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
+
+ @query('#allowBrowserNotifications')
+ allowBrowserNotifications?: HTMLInputElement;
+
+ @query('#allowSuggestCodeWhileCommenting')
+ allowSuggestCodeWhileCommenting?: HTMLInputElement;
+
+ @query('#defaultBaseForMergesSelect')
+ defaultBaseForMergesSelect!: HTMLInputElement;
+
+ @query('#relativeDateInChangeTable')
+ relativeDateInChangeTable!: HTMLInputElement;
+
+ @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
+
+ @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
+
+ @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
+
+ @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
+
+ @query('#disableKeyboardShortcuts')
+ disableKeyboardShortcuts!: HTMLInputElement;
+
+ @query('#disableTokenHighlighting')
+ disableTokenHighlighting!: HTMLInputElement;
+
+ @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
+
+ @state() prefs?: PreferencesInput;
+
+ @state() private originalPrefs?: PreferencesInput;
+
+ @state() account?: AccountDetailInfo;
+
+ @state() private docsBaseUrl = '';
+
+ @state()
+ suggestionsProvider?: SuggestionsProvider;
+
+ readonly getUserModel = resolve(this, userModelToken);
+
+ private readonly getConfigModel = resolve(this, configModelToken);
+
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+ // private but used in test
+ readonly flagsService = getAppContext().flagsService;
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getUserModel().preferences$,
+ prefs => {
+ this.originalPrefs = prefs;
+ this.prefs = {...prefs};
+ }
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ acc => {
+ this.account = acc;
+ }
+ );
+ subscribe(
+ this,
+ () => this.getConfigModel().docsBaseUrl$,
+ docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+ );
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ const suggestionsPlugins =
+ this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+ // We currently support results from only 1 provider.
+ this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
+ });
+ }
+
+ static override get styles() {
+ return [
+ sharedStyles,
+ menuPageStyles,
+ grFormStyles,
+ css`
+ :host {
+ border: none;
+ margin-bottom: var(--spacing-xxl);
+ }
+ 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);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ return html`
+ <h2 id="Preferences" class=${this.hasUnsavedChanges() ? 'edited' : ''}>
+ Preferences
+ </h2>
+ <fieldset id="preferences">
+ <div id="preferences" class="gr-form-styles">
+ <section>
+ <label class="title" for="themeSelect">Theme</label>
+ <span class="value">
+ <gr-select
+ .bindValue=${this.prefs?.theme ?? AppTheme.AUTO}
+ @change=${() => {
+ this.prefs!.theme = this.themeSelect.value as AppTheme;
+ this.requestUpdate();
+ }}
+ >
+ <select id="themeSelect">
+ <option value="AUTO">Auto (based on OS prefs)</option>
+ <option value="LIGHT">Light</option>
+ <option value="DARK">Dark</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <section>
+ <label class="title" for="changesPerPageSelect"
+ >Changes per page</label
+ >
+ <span class="value">
+ <gr-select
+ .bindValue=${convertToString(this.prefs?.changes_per_page)}
+ @change=${() => {
+ this.prefs!.changes_per_page = Number(
+ this.changesPerPageSelect.value
+ ) as 10 | 25 | 50 | 100;
+ this.requestUpdate();
+ }}
+ >
+ <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=${convertToString(this.prefs?.date_format)}
+ @change=${() => {
+ this.prefs!.date_format = this.dateTimeFormatSelect
+ .value as DateFormat;
+ this.requestUpdate();
+ }}
+ >
+ <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=${convertToString(this.prefs?.time_format)}
+ aria-label="Time Format"
+ @change=${() => {
+ this.prefs!.time_format = this.timeFormatSelect
+ .value as TimeFormat;
+ this.requestUpdate();
+ }}
+ >
+ <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=${convertToString(this.prefs?.email_strategy)}
+ @change=${() => {
+ this.prefs!.email_strategy = this.emailNotificationsSelect
+ .value as EmailStrategy;
+ this.requestUpdate();
+ }}
+ >
+ <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
+ .bindValue=${convertToString(this.prefs?.email_format)}
+ @change=${() => {
+ this.prefs!.email_format = this.emailFormatSelect
+ .value as EmailFormat;
+ this.requestUpdate();
+ }}
+ >
+ <select id="emailFormatSelect">
+ <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+ <option value="PLAINTEXT">Plaintext only</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ ${this.renderBrowserNotifications()}
+ ${this.renderGenerateSuggestionWhenCommenting()}
+ ${this.renderDefaultBaseForMerges()}
+ <section>
+ <label class="title" for="relativeDateInChangeTable"
+ >Show Relative Dates In Changes Table</label
+ >
+ <span class="value">
+ <input
+ id="relativeDateInChangeTable"
+ type="checkbox"
+ ?checked=${this.prefs?.relative_date_in_change_table}
+ @change=${() => {
+ this.prefs!.relative_date_in_change_table =
+ this.relativeDateInChangeTable.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <span class="title">Diff view</span>
+ <span class="value">
+ <gr-select
+ .bindValue=${convertToString(this.prefs?.diff_view)}
+ @change=${() => {
+ this.prefs!.diff_view = this.diffViewSelect
+ .value as DiffViewMode;
+ this.requestUpdate();
+ }}
+ >
+ <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.prefs?.size_bar_in_change_table}
+ @change=${() => {
+ this.prefs!.size_bar_in_change_table =
+ this.showSizeBarsInFileList.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="publishCommentsOnPush" class="title"
+ >Publish comments on push</label
+ >
+ <span class="value">
+ <input
+ id="publishCommentsOnPush"
+ type="checkbox"
+ ?checked=${this.prefs?.publish_comments_on_push}
+ @change=${() => {
+ this.prefs!.publish_comments_on_push =
+ this.publishCommentsOnPush.checked;
+ this.requestUpdate();
+ }}
+ />
+ </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.prefs?.work_in_progress_by_default}
+ @change=${() => {
+ this.prefs!.work_in_progress_by_default =
+ this.workInProgressByDefault.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="disableKeyboardShortcuts" class="title"
+ >Disable all keyboard shortcuts</label
+ >
+ <span class="value">
+ <input
+ id="disableKeyboardShortcuts"
+ type="checkbox"
+ ?checked=${this.prefs?.disable_keyboard_shortcuts}
+ @change=${() => {
+ this.prefs!.disable_keyboard_shortcuts =
+ this.disableKeyboardShortcuts.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ <section>
+ <label for="disableTokenHighlighting" class="title"
+ >Disable token highlighting on hover</label
+ >
+ <span class="value">
+ <input
+ id="disableTokenHighlighting"
+ type="checkbox"
+ ?checked=${this.prefs?.disable_token_highlighting}
+ @change=${() => {
+ this.prefs!.disable_token_highlighting =
+ this.disableTokenHighlighting.checked;
+ this.requestUpdate();
+ }}
+ />
+ </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.prefs?.signed_off_by}
+ @change=${() => {
+ this.prefs!.signed_off_by = this.insertSignedOff.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ </div>
+ <gr-button
+ id="savePrefs"
+ @click=${async () => {
+ await this.save();
+ }}
+ ?disabled=${!this.hasUnsavedChanges()}
+ >Save changes</gr-button
+ >
+ </fieldset>
+ `;
+ }
+
+ // When the experiment is over, move this back to render(),
+ // removing this function.
+ private renderBrowserNotifications() {
+ if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
+ return nothing;
+ if (
+ !this.flagsService.isEnabled(
+ KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+ ) &&
+ !areNotificationsEnabled(this.account)
+ )
+ return nothing;
+ return html` <section id="allowBrowserNotificationsSection">
+ <div class="title">
+ <label for="allowBrowserNotifications"
+ >Allow browser notifications</label
+ >
+ <a
+ href=${getDocUrl(
+ this.docsBaseUrl,
+ 'user-attention-set.html#_browser_notifications'
+ )}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <gr-icon icon="help" title="read documentation"></gr-icon>
+ </a>
+ </div>
+ <span class="value">
+ <input
+ id="allowBrowserNotifications"
+ type="checkbox"
+ ?checked=${this.prefs?.allow_browser_notifications}
+ @change=${() => {
+ this.prefs!.allow_browser_notifications =
+ this.allowBrowserNotifications!.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>`;
+ }
+
+ // When the experiment is over, move this back to render(),
+ // removing this function.
+ private renderGenerateSuggestionWhenCommenting() {
+ if (
+ !this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
+ !this.suggestionsProvider
+ )
+ return nothing;
+ return html`
+ <section id="allowSuggestCodeWhileCommentingSection">
+ <div class="title">
+ <label for="allowSuggestCodeWhileCommenting"
+ >AI suggested fixes while commenting</label
+ >
+ <a
+ href=${this.suggestionsProvider.getDocumentationLink?.() ||
+ getDocUrl(
+ this.docsBaseUrl,
+ 'user-suggest-edits.html#_generate_suggestion'
+ )}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <gr-icon icon="help" title="read documentation"></gr-icon>
+ </a>
+ </div>
+ <span class="value">
+ <input
+ id="allowSuggestCodeWhileCommenting"
+ type="checkbox"
+ ?checked=${this.prefs?.allow_suggest_code_while_commenting}
+ @change=${() => {
+ this.prefs!.allow_suggest_code_while_commenting =
+ this.allowSuggestCodeWhileCommenting!.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ `;
+ }
+
+ // When this is fixed and can be re-enabled, move this back to render()
+ // and remove function.
+ private renderDefaultBaseForMerges() {
+ if (!this.prefs?.default_base_for_merges) return nothing;
+ return nothing;
+ // TODO: Re-enable respecting the default_base_for_merges preference.
+ // See corresponding TODO in change-model.
+ // return html`
+ // <section>
+ // <span class="title">Default Base For Merges</span>
+ // <span class="value">
+ // <gr-select
+ // .bindValue=${convertToString(
+ // this.prefs?.default_base_for_merges
+ // )}
+ // @change=${() => {
+ // this.prefs!.default_base_for_merges = this
+ // .defaultBaseForMergesSelect.value as DefaultBase;
+ // this.requestUpdate();
+ // }}
+ // >
+ // <select id="defaultBaseForMergesSelect">
+ // <option value="AUTO_MERGE">Auto Merge</option>
+ // <option value="FIRST_PARENT">First Parent</option>
+ // </select>
+ // </gr-select>
+ // </span>
+ // </section>
+ // `;
+ }
+
+ // private but used in test
+ hasUnsavedChanges() {
+ // We have to wrap boolean values in Boolean() to ensure undefined values
+ // use false rather than undefined.
+ return (
+ this.originalPrefs?.theme !== this.prefs?.theme ||
+ this.originalPrefs?.changes_per_page !== this.prefs?.changes_per_page ||
+ this.originalPrefs?.date_format !== this.prefs?.date_format ||
+ this.originalPrefs?.time_format !== this.prefs?.time_format ||
+ this.originalPrefs?.email_strategy !== this.prefs?.email_strategy ||
+ this.originalPrefs?.email_format !== this.prefs?.email_format ||
+ Boolean(this.originalPrefs?.allow_browser_notifications) !==
+ Boolean(this.prefs?.allow_browser_notifications) ||
+ Boolean(this.originalPrefs?.allow_suggest_code_while_commenting) !==
+ Boolean(this.prefs?.allow_suggest_code_while_commenting) ||
+ this.originalPrefs?.default_base_for_merges !==
+ this.prefs?.default_base_for_merges ||
+ Boolean(this.originalPrefs?.relative_date_in_change_table) !==
+ Boolean(this.prefs?.relative_date_in_change_table) ||
+ this.originalPrefs?.diff_view !== this.prefs?.diff_view ||
+ Boolean(this.originalPrefs?.size_bar_in_change_table) !==
+ Boolean(this.prefs?.size_bar_in_change_table) ||
+ Boolean(this.originalPrefs?.publish_comments_on_push) !==
+ Boolean(this.prefs?.publish_comments_on_push) ||
+ Boolean(this.originalPrefs?.work_in_progress_by_default) !==
+ Boolean(this.prefs?.work_in_progress_by_default) ||
+ Boolean(this.originalPrefs?.disable_keyboard_shortcuts) !==
+ Boolean(this.prefs?.disable_keyboard_shortcuts) ||
+ Boolean(this.originalPrefs?.disable_token_highlighting) !==
+ Boolean(this.prefs?.disable_token_highlighting) ||
+ Boolean(this.originalPrefs?.signed_off_by) !==
+ Boolean(this.prefs?.signed_off_by)
+ );
+ }
+
+ async save() {
+ if (!this.prefs) return;
+ await this.getUserModel().updatePreferences(this.prefs);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-preferences': GrPreferences;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
new file mode 100644
index 0000000..818af06
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
@@ -0,0 +1,454 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-preferences';
+import {
+ queryAll,
+ queryAndAssert,
+ stubFlags,
+ stubRestApi,
+ waitUntil,
+} from '../../../test/test-utils';
+import {GrPreferences} from './gr-preferences';
+import {PreferencesInfo, TopMenuItemInfo} from '../../../types/common';
+import {
+ AppTheme,
+ DateFormat,
+ DefaultBase,
+ DiffViewMode,
+ EmailFormat,
+ EmailStrategy,
+ TimeFormat,
+ createDefaultPreferences,
+} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import {
+ createAccountDetailWithId,
+ createPreferences,
+} from '../../../test/test-data-generators';
+
+suite('gr-preferences tests', () => {
+ let element: GrPreferences;
+ let preferences: PreferencesInfo;
+
+ function valueOf(title: string, id: string): Element {
+ const sections = queryAll(element, `#${id} section`) ?? [];
+ let titleEl;
+ for (let i = 0; i < sections.length; i++) {
+ titleEl = sections[i].querySelector('.title');
+ if (titleEl?.textContent?.trim() === title) {
+ const el = sections[i].querySelector('.value');
+ if (el) return el;
+ }
+ }
+ assert.fail(`element with title ${title} not found`);
+ }
+
+ setup(async () => {
+ preferences = {
+ ...createPreferences(),
+ changes_per_page: 25,
+ theme: AppTheme.LIGHT,
+ date_format: DateFormat.UK,
+ time_format: TimeFormat.HHMM_12,
+ diff_view: DiffViewMode.UNIFIED,
+ email_strategy: EmailStrategy.ENABLED,
+ email_format: EmailFormat.HTML_PLAINTEXT,
+ default_base_for_merges: DefaultBase.FIRST_PARENT,
+ relative_date_in_change_table: false,
+ size_bar_in_change_table: true,
+ my: [
+ {url: '/first/url', name: 'first name', target: '_blank'},
+ {url: '/second/url', name: 'second name', target: '_blank'},
+ ] as TopMenuItemInfo[],
+ change_table: [],
+ };
+
+ stubRestApi('getPreferences').returns(Promise.resolve(preferences));
+
+ element = await fixture(html`<gr-preferences></gr-preferences>`);
+
+ await element.updateComplete;
+ });
+
+ test('renders', () => {
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <h2 id="Preferences">Preferences</h2>
+ <fieldset id="preferences">
+ <div class="gr-form-styles" id="preferences">
+ <section>
+ <label class="title" for="themeSelect"> Theme </label>
+ <span class="value">
+ <gr-select>
+ <select id="themeSelect">
+ <option value="AUTO">Auto (based on OS prefs)</option>
+ <option value="LIGHT">Light</option>
+ <option value="DARK">Dark</option>
+ </select>
+ </gr-select>
+ </span>
+ </section>
+ <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>
+ <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>
+ </div>
+ <gr-button
+ aria-disabled="true"
+ disabled=""
+ id="savePrefs"
+ role="button"
+ tabindex="-1"
+ >
+ Save changes
+ </gr-button>
+ </fieldset>
+ `
+ );
+ });
+
+ test('allow browser notifications', async () => {
+ stubFlags('isEnabled').returns(true);
+ element.account = createAccountDetailWithId();
+ await element.updateComplete;
+ assert.dom.equal(
+ queryAndAssert(element, '#allowBrowserNotificationsSection'),
+ /* HTML */ `<section id="allowBrowserNotificationsSection">
+ <div class="title">
+ <label for="allowBrowserNotifications">
+ Allow browser notifications
+ </label>
+ <a
+ href="/Documentation/user-attention-set.html#_browser_notifications"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <gr-icon icon="help" title="read documentation"> </gr-icon>
+ </a>
+ </div>
+ <span class="value">
+ <input checked="" id="allowBrowserNotifications" type="checkbox" />
+ </span>
+ </section>`
+ );
+ });
+
+ test('input values match preferences', () => {
+ // Rendered with the expected preferences selected.
+ assert.equal(
+ Number(
+ (
+ valueOf('Changes per page', 'preferences')!
+ .firstElementChild as GrSelect
+ ).bindValue
+ ),
+ preferences.changes_per_page
+ );
+ assert.equal(
+ (valueOf('Theme', 'preferences').firstElementChild as GrSelect).bindValue,
+ preferences.theme
+ );
+ assert.equal(
+ (
+ valueOf('Date/time format', 'preferences')!
+ .firstElementChild as GrSelect
+ ).bindValue,
+ preferences.date_format
+ );
+ assert.equal(
+ (valueOf('Date/time format', 'preferences')!.lastElementChild as GrSelect)
+ .bindValue,
+ preferences.time_format
+ );
+ assert.equal(
+ (
+ valueOf('Email notifications', 'preferences')!
+ .firstElementChild as GrSelect
+ ).bindValue,
+ preferences.email_strategy
+ );
+ assert.equal(
+ (valueOf('Email format', 'preferences')!.firstElementChild as GrSelect)
+ .bindValue,
+ preferences.email_format
+ );
+ assert.equal(
+ (
+ valueOf('Show Relative Dates In Changes Table', 'preferences')!
+ .firstElementChild as HTMLInputElement
+ ).checked,
+ false
+ );
+ assert.equal(
+ (valueOf('Diff view', 'preferences')!.firstElementChild as GrSelect)
+ .bindValue,
+ preferences.diff_view
+ );
+ assert.equal(
+ (
+ valueOf('Show size bars in file list', 'preferences')!
+ .firstElementChild as HTMLInputElement
+ ).checked,
+ true
+ );
+ assert.equal(
+ (
+ valueOf('Publish comments on push', 'preferences')!
+ .firstElementChild as HTMLInputElement
+ ).checked,
+ false
+ );
+ assert.equal(
+ (
+ valueOf(
+ 'Set new changes to "work in progress" by default',
+ 'preferences'
+ )!.firstElementChild as HTMLInputElement
+ ).checked,
+ false
+ );
+ assert.equal(
+ (
+ valueOf('Disable token highlighting on hover', 'preferences')!
+ .firstElementChild as HTMLInputElement
+ ).checked,
+ false
+ );
+ assert.equal(
+ (
+ valueOf(
+ 'Insert Signed-off-by Footer For Inline Edit Changes',
+ 'preferences'
+ )!.firstElementChild as HTMLInputElement
+ ).checked,
+ false
+ );
+
+ assert.isFalse(element.hasUnsavedChanges());
+ });
+
+ test('save changes', async () => {
+ assert.equal(element.prefs?.theme, AppTheme.LIGHT);
+
+ const themeSelect = valueOf('Theme', 'preferences')
+ .firstElementChild as GrSelect;
+ themeSelect.bindValue = AppTheme.DARK;
+
+ themeSelect.dispatchEvent(
+ new CustomEvent('change', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ const publishOnPush = valueOf('Publish comments on push', 'preferences')!
+ .firstElementChild! as HTMLSpanElement;
+
+ publishOnPush.click();
+
+ assert.isTrue(element.hasUnsavedChanges());
+
+ const savePrefStub = stubRestApi('savePreferences').resolves(
+ element.prefs as PreferencesInfo
+ );
+
+ await element.save();
+
+ // Wait for model state update, since this is not awaited by element.save()
+ await waitUntil(
+ () =>
+ element.getUserModel().getState().preferences?.theme === AppTheme.DARK
+ );
+ await waitUntil(
+ () => element.getUserModel().getState().preferences?.my === preferences.my
+ );
+ await waitUntil(
+ () =>
+ element.getUserModel().getState().preferences
+ ?.publish_comments_on_push === true
+ );
+
+ assert.isTrue(savePrefStub.called);
+ assert.isFalse(element.hasUnsavedChanges());
+ });
+
+ test('publish comments on push', async () => {
+ assert.isFalse(element.hasUnsavedChanges());
+
+ const publishCommentsOnPush = valueOf(
+ 'Publish comments on push',
+ 'preferences'
+ )!.firstElementChild! as HTMLSpanElement;
+ publishCommentsOnPush.click();
+
+ assert.isTrue(element.hasUnsavedChanges());
+
+ stubRestApi('savePreferences').callsFake(prefs => {
+ assert.equal(prefs.publish_comments_on_push, true);
+ return Promise.resolve(createDefaultPreferences());
+ });
+
+ // Save the change.
+ await element.save();
+ assert.isFalse(element.hasUnsavedChanges());
+ });
+
+ test('set new changes work-in-progress', async () => {
+ assert.isFalse(element.hasUnsavedChanges());
+
+ const newChangesWorkInProgress = valueOf(
+ 'Set new changes to "work in progress" by default',
+ 'preferences'
+ )!.firstElementChild! as HTMLSpanElement;
+ newChangesWorkInProgress.click();
+
+ assert.isTrue(element.hasUnsavedChanges());
+
+ stubRestApi('savePreferences').callsFake(prefs => {
+ assert.equal(prefs.work_in_progress_by_default, true);
+ return Promise.resolve(createDefaultPreferences());
+ });
+
+ // Save the change.
+ await element.save();
+ assert.isFalse(element.hasUnsavedChanges());
+ });
+});
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 b002fc8..fd7d67b 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
@@ -21,6 +21,7 @@
import '../gr-http-password/gr-http-password';
import '../gr-identities/gr-identities';
import '../gr-menu-editor/gr-menu-editor';
+import '../gr-preferences/gr-preferences';
import '../gr-ssh-editor/gr-ssh-editor';
import '../gr-watched-projects-editor/gr-watched-projects-editor';
import '../../shared/gr-dialog/gr-dialog';
@@ -39,17 +40,8 @@
import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
import {fireAlert, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
-import {
- DateFormat,
- DefaultBase,
- DiffViewMode,
- EmailFormat,
- EmailStrategy,
- AppTheme,
- TimeFormat,
-} from '../../../constants/constants';
import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
-import {LitElement, css, html, nothing} from 'lit';
+import {LitElement, css, html} from 'lit';
import {customElement, query, queryAsync, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {paperStyles} from '../../../styles/gr-paper-styles';
@@ -58,29 +50,24 @@
import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
import {grFormStyles} from '../../../styles/gr-form-styles';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {settingsViewModelToken} from '../../../models/views/settings';
-import {areNotificationsEnabled} from '../../../utils/worker-util';
import {
changeTablePrefs,
userModelToken,
} from '../../../models/user/user-model';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getDocUrl, rootUrl} from '../../../utils/url-util';
-import {configModelToken} from '../../../models/config/config-model';
-import {SuggestionsProvider} from '../../../api/suggestions';
-import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {rootUrl} from '../../../utils/url-util';
const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
-enum CopyPrefsDirection {
- PrefsToLocalPrefs,
- LocalPrefsToPrefs,
-}
-
+/**
+ * This provides an interface to show all settings for a user profile.
+ * In most cases a individual module is used per setting to make
+ * code more readable. In other cases, it is created within this module.
+ */
@customElement('gr-settings-view')
export class GrSettingsView extends LitElement {
/**
@@ -109,64 +96,17 @@
@query('#emailEditor', true) emailEditor!: GrEmailEditor;
- @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
-
- @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
-
- @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
-
- @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
-
- @query('#allowBrowserNotifications')
- allowBrowserNotifications?: HTMLInputElement;
-
- @query('#allowSuggestCodeWhileCommenting')
- allowSuggestCodeWhileCommenting?: HTMLInputElement;
-
- @query('#disableKeyboardShortcuts')
- disableKeyboardShortcuts!: HTMLInputElement;
-
- @query('#disableTokenHighlighting')
- disableTokenHighlighting!: HTMLInputElement;
-
- @query('#relativeDateInChangeTable')
- relativeDateInChangeTable!: HTMLInputElement;
-
- @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
-
- @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
-
- @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
-
- @query('#emailNotificationsSelect')
- emailNotificationsSelect!: HTMLInputElement;
-
- @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
-
- @query('#defaultBaseForMergesSelect')
- defaultBaseForMergesSelect!: HTMLInputElement;
-
- @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
-
- @query('#themeSelect') themeSelect!: HTMLInputElement;
-
@state() prefs: PreferencesInput = {};
@state() private accountInfoChanged = false;
// private but used in test
- @state() 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;
@@ -199,11 +139,6 @@
@state() isDeletingAccount = false;
- @state() private docsBaseUrl = '';
-
- @state()
- suggestionsProvider?: SuggestionsProvider;
-
// private but used in test
public _testOnly_loadingPromise?: Promise<void>;
@@ -218,10 +153,6 @@
private readonly getNavigation = resolve(this, navigationToken);
- private readonly getConfigModel = resolve(this, configModelToken);
-
- private readonly getPluginLoader = resolve(this, pluginLoaderToken);
-
constructor() {
super();
subscribe(
@@ -248,16 +179,9 @@
}
this.prefs = prefs;
this.showNumber = !!prefs.legacycid_in_change_table;
- this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
- this.prefsChanged = false;
this.localChangeTableColumns = changeTablePrefs(prefs);
}
);
- subscribe(
- this,
- () => this.getConfigModel().docsBaseUrl$,
- docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
- );
}
// private, but used in tests
@@ -275,14 +199,6 @@
// we need to manually calling scrollIntoView when hash changed
document.addEventListener('location-change', this.handleLocationChange);
fireTitleChange('Settings');
- this.getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => {
- const suggestionsPlugins =
- this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
- // We currently support results from only 1 provider.
- this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
- });
}
override firstUpdated() {
@@ -459,32 +375,7 @@
</gr-dialog>
</dialog>
</fieldset>
- <h2
- id="Preferences"
- class=${this.computeHeaderClass(this.prefsChanged)}
- >
- Preferences
- </h2>
- <fieldset id="preferences">
- ${this.renderTheme()} ${this.renderChangesPerPages()}
- ${this.renderDateTimeFormat()} ${this.renderEmailNotification()}
- ${this.renderEmailFormat()} ${this.renderBrowserNotifications()}
- ${this.renderGenerateSuggestionWhenCommenting()}
- ${this.renderDefaultBaseForMerges()}
- ${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
- ${this.renderShowSizeBarsInFileList()}
- ${this.renderPublishCommentsOnPush()}
- ${this.renderWorkInProgressByDefault()}
- ${this.renderDisableKeyboardShortcuts()}
- ${this.renderDisableTokenHighlighting()}
- ${this.renderInsertSignedOff()}
- <gr-button
- id="savePrefs"
- @click=${this.handleSavePreferences}
- ?disabled=${!this.prefsChanged}
- >Save changes</gr-button
- >
- </fieldset>
+ <gr-preferences id="preferences"></gr-preferences>
<h2
id="DiffPreferences"
class=${this.computeHeaderClass(this.diffPrefsChanged)}
@@ -701,436 +592,6 @@
super.disconnectedCallback();
}
- private renderTheme() {
- return html`
- <section>
- <label class="title" for="themeSelect">Theme</label>
- <span class="value">
- <gr-select
- .bindValue=${this.localPrefs.theme ?? AppTheme.AUTO}
- @change=${() => {
- this.localPrefs.theme = this.themeSelect.value as AppTheme;
- this.prefsChanged = true;
- }}
- >
- <select id="themeSelect">
- <option value="AUTO">Auto (based on OS prefs)</option>
- <option value="LIGHT">Light</option>
- <option value="DARK">Dark</option>
- </select>
- </gr-select>
- </span>
- </section>
- `;
- }
-
- private renderChangesPerPages() {
- return html`
- <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>
- `;
- }
-
- private renderDateTimeFormat() {
- return html`
- <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>
- `;
- }
-
- private renderEmailNotification() {
- return html`
- <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>
- `;
- }
-
- private renderEmailFormat() {
- if (!this.localPrefs.email_format) return nothing;
- return html`
- <section>
- <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>
- `;
- }
-
- private renderBrowserNotifications() {
- if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
- return nothing;
- if (
- !this.flagsService.isEnabled(
- KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
- ) &&
- !areNotificationsEnabled(this.account)
- )
- return nothing;
- return html`
- <section id="allowBrowserNotificationsSection">
- <div class="title">
- <label for="allowBrowserNotifications"
- >Allow browser notifications</label
- >
- <a
- href=${getDocUrl(
- this.docsBaseUrl,
- 'user-attention-set.html#_browser_notifications'
- )}
- target="_blank"
- rel="noopener noreferrer"
- >
- <gr-icon icon="help" title="read documentation"></gr-icon>
- </a>
- </div>
- <span class="value">
- <input
- id="allowBrowserNotifications"
- type="checkbox"
- ?checked=${this.localPrefs.allow_browser_notifications}
- @change=${() => {
- this.localPrefs.allow_browser_notifications =
- this.allowBrowserNotifications!.checked;
- this.prefsChanged = true;
- }}
- />
- </span>
- </section>
- `;
- }
-
- private renderGenerateSuggestionWhenCommenting() {
- if (
- !this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
- !this.suggestionsProvider
- )
- return nothing;
- return html`
- <section id="allowSuggestCodeWhileCommentingSection">
- <div class="title">
- <label for="allowSuggestCodeWhileCommenting"
- >AI suggested fixes while commenting</label
- >
- <a
- href=${this.suggestionsProvider.getDocumentationLink?.() ||
- getDocUrl(
- this.docsBaseUrl,
- 'user-suggest-edits.html#_generate_suggestion'
- )}
- target="_blank"
- rel="noopener noreferrer"
- >
- <gr-icon icon="help" title="read documentation"></gr-icon>
- </a>
- </div>
- <span class="value">
- <input
- id="allowSuggestCodeWhileCommenting"
- type="checkbox"
- ?checked=${this.localPrefs.allow_suggest_code_while_commenting}
- @change=${() => {
- this.localPrefs.allow_suggest_code_while_commenting =
- this.allowSuggestCodeWhileCommenting!.checked;
- this.prefsChanged = true;
- }}
- />
- </span>
- </section>
- `;
- }
-
- private renderDefaultBaseForMerges() {
- if (!this.localPrefs.default_base_for_merges) return nothing;
- return nothing;
- // TODO: Re-enable respecting the default_base_for_merges preference.
- // See corresponding TODO in change-model.
- // return html`
- // <section>
- // <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>
- // `;
- }
-
- private renderRelativeDateInChangeTable() {
- return html`
- <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>
- `;
- }
-
- private renderDiffView() {
- return html`
- <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>
- `;
- }
-
- private renderShowSizeBarsInFileList() {
- return html`
- <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>
- `;
- }
-
- private renderPublishCommentsOnPush() {
- return html`
- <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>
- `;
- }
-
- private renderWorkInProgressByDefault() {
- return html`
- <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>
- `;
- }
-
- private renderDisableKeyboardShortcuts() {
- return html`
- <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>
- `;
- }
-
- private renderDisableTokenHighlighting() {
- return html`
- <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>
- `;
- }
-
- private renderInsertSignedOff() {
- return html`
- <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>
- `;
- }
-
private readonly handleLocationChange = () => {
// Handle anchor tag after dom attached
const urlHash = window.location.hash;
@@ -1147,30 +608,12 @@
Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
}
- private copyPrefs(direction: CopyPrefsDirection) {
- if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
- this.prefs = {
- ...this.localPrefs,
- };
- } else {
- this.localPrefs = {
- ...this.prefs,
- };
- }
- }
-
// private but used in test
- handleSavePreferences() {
- return this.getUserModel().updatePreferences(this.localPrefs);
- }
-
- // private but used in test
- handleSaveChangeTable() {
+ async 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;
- });
+ await this.getUserModel().updatePreferences(this.prefs);
+ this.changeTableChanged = false;
}
private computeHeaderClass(changed?: boolean) {
@@ -1235,25 +678,6 @@
return false;
}
-
- /**
- * bind-value has type string so we have to convert anything inputed
- * to string.
- *
- * This is so typescript template checker doesn't fail.
- */
- private convertToString(
- key?:
- | DateFormat
- | DefaultBase
- | DiffViewMode
- | EmailFormat
- | EmailStrategy
- | TimeFormat
- | number
- ) {
- return key !== undefined ? String(key) : '';
- }
}
declare global {
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 f9b1738..b6690b6 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
@@ -6,13 +6,7 @@
import '../../../test/common-test-setup';
import './gr-settings-view';
import {GrSettingsView} from './gr-settings-view';
-import {
- queryAll,
- queryAndAssert,
- stubFlags,
- stubRestApi,
- waitEventLoop,
-} from '../../../test/test-utils';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
import {
AuthInfo,
AccountDetailInfo,
@@ -22,7 +16,6 @@
TopMenuItemInfo,
} from '../../../types/common';
import {
- createDefaultPreferences,
DateFormat,
DefaultBase,
DiffViewMode,
@@ -36,7 +29,6 @@
createPreferences,
createServerInfo,
} from '../../../test/test-data-generators';
-import {GrSelect} from '../../shared/gr-select/gr-select';
import {fixture, html, assert} from '@open-wc/testing';
suite('gr-settings-view tests', () => {
@@ -45,36 +37,6 @@
let preferences: PreferencesInfo;
let config: ServerInfo;
- function valueOf(title: string, id: string) {
- const sections = queryAll(element, `#${id} section`);
- let titleEl;
- for (let i = 0; i < sections.length; i++) {
- titleEl = sections[i].querySelector('.title');
- if (titleEl?.textContent?.trim() === title) {
- const el = sections[i].querySelector('.value');
- if (el) return el;
- }
- }
- assert.fail(`element with title ${title} not found`);
- }
-
- // Because deepEqual isn't behaving in Safari.
- function assertMenusEqual(
- actual?: TopMenuItemInfo[],
- expected?: TopMenuItemInfo[]
- ) {
- if (actual === undefined) {
- assert.fail("assertMenusEqual 'actual' param is undefined");
- } else if (expected === undefined) {
- assert.fail("assertMenusEqual 'expected' param is undefined");
- }
- assert.equal(actual.length, expected.length);
- for (let i = 0; i < actual.length; i++) {
- assert.equal(actual[i].name, expected[i].name);
- assert.equal(actual[i].url, expected[i].url);
- }
- }
-
function stubAddAccountEmail(statusCode: number) {
return stubRestApi('addAccountEmail').callsFake(() =>
Promise.resolve({status: statusCode} as Response)
@@ -193,172 +155,7 @@
</gr-dialog>
</dialog>
</fieldset>
- <h2 id="Preferences">Preferences</h2>
- <fieldset id="preferences">
- <section>
- <label class="title" for="themeSelect">
- Theme
- </label>
- <span class="value">
- <gr-select>
- <select id="themeSelect">
- <option value="AUTO">Auto (based on OS prefs)</option>
- <option value="LIGHT">Light</option>
- <option value="DARK">Dark</option>
- </select>
- </gr-select>
- </span>
- </section>
- <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>
- <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>
+ <gr-preferences id="preferences"> </gr-preferences>
<h2 id="DiffPreferences">Diff Preferences</h2>
<fieldset id="diffPreferences">
<gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
@@ -456,33 +253,6 @@
);
});
- test('allow browser notifications', async () => {
- stubFlags('isEnabled').returns(true);
- element = await fixture(html`<gr-settings-view></gr-settings-view>`);
- element.account = createAccountDetailWithId();
- await element.updateComplete;
- assert.dom.equal(
- queryAndAssert(element, '#allowBrowserNotificationsSection'),
- /* HTML */ `<section id="allowBrowserNotificationsSection">
- <div class="title">
- <label for="allowBrowserNotifications">
- Allow browser notifications
- </label>
- <a
- href="/Documentation/user-attention-set.html#_browser_notifications"
- target="_blank"
- rel="noopener noreferrer"
- >
- <gr-icon icon="help" title="read documentation"> </gr-icon>
- </a>
- </div>
- <span class="value">
- <input checked="" id="allowBrowserNotifications" type="checkbox" />
- </span>
- </section>`
- );
- });
-
test('calls the title-change event', async () => {
const titleChangedStub = sinon.stub();
const newElement = document.createElement('gr-settings-view');
@@ -497,167 +267,6 @@
assert.equal(titleChangedStub.getCall(0).args[0].detail.title, 'Settings');
});
- test('user preferences', async () => {
- // Rendered with the expected preferences selected.
- assert.equal(
- Number(
- (
- valueOf('Changes per page', 'preferences')!
- .firstElementChild as GrSelect
- ).bindValue
- ),
- preferences.changes_per_page
- );
- assert.equal(
- (valueOf('Theme', 'preferences').firstElementChild as GrSelect).bindValue,
- preferences.theme
- );
- assert.equal(
- (
- valueOf('Date/time format', 'preferences')!
- .firstElementChild as GrSelect
- ).bindValue,
- preferences.date_format
- );
- assert.equal(
- (valueOf('Date/time format', 'preferences')!.lastElementChild as GrSelect)
- .bindValue,
- preferences.time_format
- );
- assert.equal(
- (
- valueOf('Email notifications', 'preferences')!
- .firstElementChild as GrSelect
- ).bindValue,
- preferences.email_strategy
- );
- assert.equal(
- (valueOf('Email format', 'preferences')!.firstElementChild as GrSelect)
- .bindValue,
- preferences.email_format
- );
- assert.equal(
- (
- valueOf('Show Relative Dates In Changes Table', 'preferences')!
- .firstElementChild as HTMLInputElement
- ).checked,
- false
- );
- assert.equal(
- (valueOf('Diff view', 'preferences')!.firstElementChild as GrSelect)
- .bindValue,
- preferences.diff_view
- );
- assert.equal(
- (
- valueOf('Show size bars in file list', 'preferences')!
- .firstElementChild as HTMLInputElement
- ).checked,
- true
- );
- assert.equal(
- (
- valueOf('Publish comments on push', 'preferences')!
- .firstElementChild as HTMLInputElement
- ).checked,
- false
- );
- assert.equal(
- (
- valueOf(
- 'Set new changes to "work in progress" by default',
- 'preferences'
- )!.firstElementChild as HTMLInputElement
- ).checked,
- false
- );
- assert.equal(
- (
- valueOf('Disable token highlighting on hover', 'preferences')!
- .firstElementChild as HTMLInputElement
- ).checked,
- false
- );
- assert.equal(
- (
- valueOf(
- 'Insert Signed-off-by Footer For Inline Edit Changes',
- 'preferences'
- )!.firstElementChild as HTMLInputElement
- ).checked,
- false
- );
-
- assert.isFalse(element.prefsChanged);
-
- const themeSelect = valueOf('Theme', 'preferences')
- .firstElementChild as GrSelect;
- themeSelect.bindValue = 'DARK';
-
- themeSelect.dispatchEvent(
- new CustomEvent('change', {
- composed: true,
- bubbles: true,
- })
- );
-
- const publishOnPush = valueOf('Publish comments on push', 'preferences')!
- .firstElementChild! as HTMLSpanElement;
-
- publishOnPush.click();
-
- assert.isTrue(element.prefsChanged);
-
- stubRestApi('savePreferences').callsFake(prefs => {
- assertMenusEqual(prefs.my, preferences.my);
- assert.equal(prefs.publish_comments_on_push, true);
- assert.equal(prefs.theme, AppTheme.DARK);
- return Promise.resolve(createDefaultPreferences());
- });
-
- // Save the change.
- await element.handleSavePreferences();
- assert.isFalse(element.prefsChanged);
- });
-
- test('publish comments on push', async () => {
- const publishCommentsOnPush = valueOf(
- 'Publish comments on push',
- 'preferences'
- )!.firstElementChild! as HTMLSpanElement;
- publishCommentsOnPush.click();
-
- assert.isTrue(element.prefsChanged);
-
- stubRestApi('savePreferences').callsFake(prefs => {
- assert.equal(prefs.publish_comments_on_push, true);
- return Promise.resolve(createDefaultPreferences());
- });
-
- // Save the change.
- await element.handleSavePreferences();
- assert.isFalse(element.prefsChanged);
- });
-
- test('set new changes work-in-progress', async () => {
- const newChangesWorkInProgress = valueOf(
- 'Set new changes to "work in progress" by default',
- 'preferences'
- )!.firstElementChild! as HTMLSpanElement;
- newChangesWorkInProgress.click();
-
- assert.isTrue(element.prefsChanged);
-
- stubRestApi('savePreferences').callsFake(prefs => {
- assert.equal(prefs.work_in_progress_by_default, true);
- return Promise.resolve(createDefaultPreferences());
- });
-
- // Save the change.
- await element.handleSavePreferences();
- assert.isFalse(element.prefsChanged);
- });
-
test('add email validation', async () => {
assert.isFalse(element.isNewEmailValid('invalid email'));
assert.isTrue(element.isNewEmailValid('vaguely@valid.email'));
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 4c73fcf..d77bec9 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -23,15 +23,15 @@
import {createSearchUrl} from '../../../models/views/search';
import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
@customElement('gr-account-label')
export class GrAccountLabel extends LitElement {
@property({type: Object})
account?: AccountInfo;
- @property({type: Object})
- _selfAccount?: AccountInfo;
-
/**
* Optional ChangeInfo object, typically comes from the change page or
* from a row in a list of search results. This is needed for some change
@@ -67,9 +67,6 @@
@property({type: Boolean})
hideAvatar = false;
- @state()
- _config?: ServerInfo;
-
@property({type: Boolean, reflect: true})
selectionChipStyle = false;
@@ -94,12 +91,24 @@
@property({type: Boolean, reflect: true})
avatarShown = false;
+ // Private but used in tests.
+ @state()
+ selfAccount?: AccountInfo;
+
+ // Private but used in tests.
+ @state()
+ config?: ServerInfo;
+
readonly reporting = getAppContext().reportingService;
private readonly restApiService = getAppContext().restApiService;
private readonly getAccountsModel = resolve(this, accountsModelToken);
+ private readonly getConfigModel = resolve(this, configModelToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
+
static override get styles() {
return [
css`
@@ -201,13 +210,13 @@
}
override render() {
- const {account, change, highlightAttention, forceAttention, _config} = this;
+ const {account, change, highlightAttention, forceAttention, config} = this;
if (!account) return;
this.attentionIconShown =
forceAttention ||
this.hasUnforcedAttention(highlightAttention, account, change);
this.deselected = !this.selected;
- const hasAvatars = !!_config?.plugin?.has_avatars;
+ const hasAvatars = !!config?.plugin?.has_avatars;
this.avatarShown = !this.hideAvatar && hasAvatars;
return html`
@@ -227,7 +236,7 @@
account,
change,
false,
- this._selfAccount
+ this.selfAccount
)}
title=${this.computeAttentionIconTitle(
highlightAttention,
@@ -235,7 +244,7 @@
change,
forceAttention,
this.selected,
- this._selfAccount
+ this.selfAccount
)}
>
<gr-button
@@ -248,7 +257,7 @@
account,
change,
this.selected,
- this._selfAccount
+ this.selfAccount
)}
>
<div>
@@ -280,7 +289,7 @@
class="name"
part="gr-account-label-text"
>
- ${this.computeName(account, this.firstName, this._config)}
+ ${this.computeName(account, this.firstName, this.config)}
</span>
${this.renderAccountStatusPlugins()}
</span>
@@ -291,12 +300,16 @@
constructor() {
super();
- this.restApiService.getConfig().then(config => {
- this._config = config;
- });
- this.restApiService.getAccount().then(account => {
- this._selfAccount = account;
- });
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ x => (this.config = x)
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ x => (this.selfAccount = x)
+ );
this.addEventListener('attention-set-updated', () => {
// For re-evaluation of everything that depends on 'change'.
if (this.change) this.change = {...this.change};
@@ -375,7 +388,7 @@
// We are deliberately updating the UI before making the API call. It is a
// risk that we are taking to achieve a better UX for 99.9% of the cases.
- const reason = getRemovedByIconClickReason(this._selfAccount, this._config);
+ const reason = getRemovedByIconClickReason(this.selfAccount, this.config);
if (this.change.attention_set)
delete this.change.attention_set[this.account._account_id];
// For re-evaluation of everything that depends on 'change'.
@@ -401,7 +414,7 @@
const targetId = this.account._account_id;
const ownerId =
(this.change && this.change.owner && this.change.owner._account_id) || -1;
- const selfId = this._selfAccount?._account_id || -1;
+ const selfId = this.selfAccount?._account_id || -1;
const reviewers =
this.change && this.change.reviewers && this.change.reviewers.REVIEWER
? [...this.change.reviewers.REVIEWER]
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index 71f8391..8b49cdd 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -154,11 +154,11 @@
suite('attention set', () => {
setup(async () => {
element.highlightAttention = true;
- element._config = {
+ element.config = {
...createServerInfo(),
user: {anonymous_coward_name: 'Anonymous Coward'},
};
- element._selfAccount = kermit;
+ element.selfAccount = kermit;
element.account = {
...createAccountDetailWithIdNameAndEmail(42),
name: 'ernie',
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 863f4f8..3509146 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -1216,6 +1216,7 @@
this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
uuid: this.generatedSuggestionId,
type: 'suggest-code',
+ commentId: this.comment.id,
response: suggestionResponse.responseCode,
numSuggestions: suggestionResponse.suggestions.length,
hasNewRange: suggestionResponse.suggestions?.[0]?.newRange !== undefined,
@@ -1267,6 +1268,7 @@
this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
uuid: this.generatedSuggestionId,
type: 'suggest-fix',
+ commentId: this.comment.id,
response: suggestionResponse.responseCode,
numSuggestions: suggestionResponse.fix_suggestions.length,
});
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 d3a4013..c533a5e 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
@@ -256,6 +256,7 @@
});
this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
uuid: this.uuid,
+ commentId: this.comment?.id ?? '',
});
if (currentPreviews.length > 0) {
this.preview = currentPreviews[0];
@@ -288,6 +289,7 @@
});
this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
uuid: this.uuid,
+ commentId: this.comment?.id ?? '',
});
if (currentPreviews.length > 0) {
this.preview = currentPreviews[0];
@@ -347,6 +349,7 @@
fileExtension: getFileExtension(
fixSuggestion?.replacements?.[0].path ?? ''
),
+ commentId: this.comment?.id ?? '',
});
if (res?.ok) {
this.getNavigation().setUrl(
@@ -375,6 +378,7 @@
if (!this.suggestion) return;
this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_ADDED, {
uuid: this.uuid,
+ commentId: this.comment?.id ?? '',
});
fire(this, 'add-generated-suggestion', {code: this.suggestion});
}