Merge changes from topic "gr-change-requirements-to-ts"
* changes:
Convert gr-change-requirements to typescript
Rename files to preserve history
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index f02d89a..879ec99 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4423,19 +4423,25 @@
[[receiveemail.filter.mode]]receiveemail.filter.mode::
+
-A black- and whitelist filter to filter incoming emails.
+An allow and block filter to filter incoming emails.
+
If `OFF`, emails are not filtered by the list filter.
+
-If `WHITELIST`, only emails where a pattern from
+If `ALLOW`, only emails where a pattern from
<<receiveemail.filter.patterns,receiveemail.filter.patterns>>
matches 'From' will be processed.
+
-If `BLACKLIST`, only emails where no pattern from
+If `BLOCK`, only emails where no pattern from
<<receiveemail.filter.patterns,receiveemail.filter.patterns>>
matches 'From' will be processed.
+
Defaults to `OFF`.
++
+The previous filter-names 'BLACKLIST' and 'WHITELIST' have been deprecated
+since they may be considered disrespectful and there's no technical or
+practical reason to use these exact terms for the filters.
+For backwards compatibility they are still supported but support for these
+deprecated terms will be removed in future releases.
[[receiveemail.filter.patterns]]receiveemail.filter.patterns::
+
@@ -4566,9 +4572,10 @@
[[sendemail.allowrcpt]]sendemail.allowrcpt::
+
-If present, each value adds one entry to the whitelist of email
-addresses that Gerrit can send email to. If set to a complete
-email address, that one address is added to the white list.
+If present, each value adds one entry to the list of allowed email
+addresses that Gerrit can send emails to. If set to a complete
+email address, that one address is added to the list of allowed
+emails.
If set to a domain name, any address at that domain can receive
email from Gerrit.
+
@@ -4579,9 +4586,10 @@
[[sendemail.denyrcpt]]sendemail.denyrcpt::
+
-If present, each value adds one entry to the blacklist of email
-addresses that Gerrit can send email to. If set to a complete
-email address, that one address is added to the blacklist.
+If present, each value adds one entry to the list of email
+addresses that Gerrit can't send emails to. If set to a complete
+email address, that one address is added to the list of blocked
+emails.
If set to a domain name, any address at that domain can *not* receive
email from Gerrit.
+
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index 2fc659d..ba73bdd 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -35,7 +35,7 @@
"gmail_quote" // Used for quoting original content
);
- private static final ImmutableSet<String> WHITELISTED_HTML_TAGS =
+ private static final ImmutableSet<String> ALLOWED_HTML_TAGS =
ImmutableSet.of(
"div", // Most user-typed comments are contained in a <div> tag
"a", // We allow links to be contained in a comment
@@ -120,8 +120,8 @@
// There is no user-input in quoted text
continue;
}
- if (!WHITELISTED_HTML_TAGS.contains(elementName)) {
- // We only accept a set of whitelisted tags that can contain user input
+ if (!ALLOWED_HTML_TAGS.contains(elementName)) {
+ // We only accept a set of allowed tags that can contain user input
continue;
}
if (elementName.equals("a") && e.attr("href").startsWith("mailto:")) {
diff --git a/java/com/google/gerrit/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
index 2f31a9c..2700f81 100644
--- a/java/com/google/gerrit/mail/MailHeader.java
+++ b/java/com/google/gerrit/mail/MailHeader.java
@@ -18,6 +18,7 @@
public enum MailHeader {
// Gerrit metadata holders
ASSIGNEE("Gerrit-Assignee"),
+ ATTENTION("Gerrit-Attention"),
BRANCH("Gerrit-Branch"),
CC("Gerrit-CC"),
COMMENT_IN_REPLY_TO("Comment-In-Reply-To"),
diff --git a/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index 23f7e12..67cef45 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -31,8 +31,8 @@
public enum ListFilterMode {
OFF,
- WHITELIST,
- BLACKLIST
+ ALLOW,
+ BLOCK
}
private final ListFilterMode mode;
@@ -40,12 +40,37 @@
@Inject
ListMailFilter(@GerritServerConfig Config cfg) {
- this.mode = cfg.getEnum("receiveemail", "filter", "mode", ListFilterMode.OFF);
+ mode = getListFilterMode(cfg);
String[] addresses = cfg.getStringList("receiveemail", "filter", "patterns");
String concat = Arrays.asList(addresses).stream().collect(joining("|"));
this.mailPattern = Pattern.compile(concat);
}
+ private static final String LEGACY_ALLOW = "WHITELIST";
+ private static final String LEGACY_BLOCK = "BLACKLIST";
+
+ /** Legacy names are supported, but should be removed in the future. */
+ private ListFilterMode getListFilterMode(Config cfg) {
+ ListFilterMode mode;
+ String modeString = cfg.getString("receiveemail", "filter", "mode");
+ if (modeString == null) {
+ modeString = "";
+ }
+ switch (modeString) {
+ case LEGACY_ALLOW:
+ case "ALLOW":
+ mode = ListFilterMode.ALLOW;
+ break;
+ case LEGACY_BLOCK:
+ case "BLOCK":
+ mode = ListFilterMode.BLOCK;
+ break;
+ default:
+ mode = ListFilterMode.OFF;
+ }
+ return mode;
+ }
+
@Override
public boolean shouldProcessMessage(MailMessage message) {
if (mode == ListFilterMode.OFF) {
@@ -53,8 +78,7 @@
}
boolean match = mailPattern.matcher(message.from().email()).find();
- if ((mode == ListFilterMode.WHITELIST && !match)
- || (mode == ListFilterMode.BLACKLIST && match)) {
+ if ((mode == ListFilterMode.ALLOW && !match) || (mode == ListFilterMode.BLOCK && match)) {
logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
return false;
}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 1e984c1..7b2bf12 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.mail.send;
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -56,6 +58,7 @@
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
+import java.util.stream.Collectors;
import org.apache.james.mime4j.dom.field.FieldName;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.internal.JGitText;
@@ -484,6 +487,9 @@
for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
footers.add(MailHeader.CC.withDelimiter() + reviewer);
}
+ for (String attentionUser : getAttentionSet()) {
+ footers.add(MailHeader.ATTENTION.withDelimiter() + attentionUser);
+ }
}
/**
@@ -509,6 +515,19 @@
return reviewers;
}
+ private Set<String> getAttentionSet() {
+ Set<String> attentionSet = new TreeSet<>();
+ try {
+ attentionSet =
+ additionsOnly(changeData.attentionSet()).stream()
+ .map(a -> getNameEmailFor(a.account()))
+ .collect(Collectors.toSet());
+ } catch (StorageException e) {
+ logger.atWarning().withCause(e).log("Cannot get change attention set");
+ }
+ return attentionSet;
+ }
+
public boolean getIncludeDiff() {
return args.settings.includeDiff;
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index ed4c33a..cc6b199 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -1355,6 +1355,30 @@
assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
}
+ @Test
+ public void attentionSetEmailFooter() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // Add user to attention set. They receive an email with the attention footer.
+ change(r).addReviewer(user.id().toString());
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .contains("Gerrit-Attention: " + user.fullName());
+ sender.clear();
+
+ // Irrelevant reply, User is still in the attention set.
+ change(r).current().review(ReviewInput.approve());
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .contains("Gerrit-Attention: " + user.fullName());
+ sender.clear();
+
+ // Abandon the change which removes user from attention set; there is an email but without the
+ // attention footer.
+ change(r).abandon();
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .doesNotContain("Gerrit-Attention: " + user.fullName());
+ sender.clear();
+ }
+
private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
PushOneCommit.Result r, TestAccount account) {
return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index e961c67..49b184b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -45,11 +45,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+ser@example\\.com", "a@b\\.com"})
- public void listFilterWhitelistDoesNotFilterListedUser() throws Exception {
+ public void listFilterAllowDoesNotFilterListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -57,11 +57,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+@gerritcodereview\\.com", "a@b\\.com"})
- public void listFilterWhitelistFiltersNotListedUser() throws Exception {
+ public void listFilterAllowFiltersNotListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have NOT been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -72,11 +72,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+@gerritcodereview\\.com", "a@b\\.com"})
- public void listFilterBlacklistDoesNotFilterNotListedUser() throws Exception {
+ public void listFilterBlockDoesNotFilterNotListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -84,11 +84,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+@example\\.com", "a@b\\.com"})
- public void listFilterBlacklistFiltersListedUser() throws Exception {
+ public void listFilterBlockFiltersListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
deleted file mode 100644
index 4989365..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ /dev/null
@@ -1,309 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '@polymer/iron-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-list-view/gr-list-view.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-pointer-dialog/gr-create-pointer-dialog.js';
-import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo-detail-list_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {encodeURL} from '../../../utils/url-util.js';
-
-const DETAIL_TYPES = {
- BRANCHES: 'branches',
- TAGS: 'tags',
-};
-
-const PGP_START = '-----BEGIN PGP SIGNATURE-----';
-
-/**
- * @appliesMixin ListViewMixin
- * @extends PolymerElement
- */
-class GrRepoDetailList extends ListViewMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-detail-list'; }
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
- /**
- * The kind of detail we are displaying, possibilities are determined by
- * the const DETAIL_TYPES.
- */
- detailType: String,
-
- _editing: {
- type: Boolean,
- value: false,
- },
- _isOwner: {
- type: Boolean,
- value: false,
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- /**
- * Offset of currently visible query results.
- */
- _offset: Number,
- _repo: Object,
- _items: Array,
- /**
- * Because we request one more than the projectsPerPage, _shownProjects
- * maybe one less than _projects.
- */
- _shownItems: {
- type: Array,
- computed: 'computeShownItems(_items)',
- },
- _itemsPerPage: {
- type: Number,
- value: 25,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _filter: String,
- _refName: String,
- _hasNewItemName: Boolean,
- _isEditing: Boolean,
- _revisedRef: String,
- };
- }
-
- _determineIfOwner(repo) {
- return this.$.restAPI.getRepoAccess(repo)
- .then(access =>
- this._isOwner = access && !!access[repo].is_owner);
- }
-
- _paramsChanged(params) {
- if (!params || !params.repo) { return; }
-
- this._repo = params.repo;
-
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- if (loggedIn) {
- this._determineIfOwner(this._repo);
- }
- });
-
- this.detailType = params.detail;
-
- this._filter = this.getFilterValue(params);
- this._offset = this.getOffsetValue(params);
-
- return this._getItems(this._filter, this._repo,
- this._itemsPerPage, this._offset, this.detailType);
- }
-
- _getItems(filter, repo, itemsPerPage, offset, detailType) {
- this._loading = true;
- this._items = [];
- flush();
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
- if (detailType === DETAIL_TYPES.BRANCHES) {
- return this.$.restAPI.getRepoBranches(
- filter, repo, itemsPerPage, offset, errFn).then(items => {
- if (!items) { return; }
- this._items = items;
- this._loading = false;
- });
- } else if (detailType === DETAIL_TYPES.TAGS) {
- return this.$.restAPI.getRepoTags(
- filter, repo, itemsPerPage, offset, errFn).then(items => {
- if (!items) { return; }
- this._items = items;
- this._loading = false;
- });
- }
- }
-
- _getPath(repo) {
- return `/admin/repos/${encodeURL(repo, false)},` +
- `${this.detailType}`;
- }
-
- _computeWeblink(repo) {
- if (!repo.web_links) { return ''; }
- const webLinks = repo.web_links;
- return webLinks.length ? webLinks : null;
- }
-
- _computeMessage(message) {
- if (!message) { return; }
- // Strip PGP info.
- return message.split(PGP_START)[0];
- }
-
- _stripRefs(item, detailType) {
- if (detailType === DETAIL_TYPES.BRANCHES) {
- return item.replace('refs/heads/', '');
- } else if (detailType === DETAIL_TYPES.TAGS) {
- return item.replace('refs/tags/', '');
- }
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _computeEditingClass(isEditing) {
- return isEditing ? 'editing' : '';
- }
-
- _computeCanEditClass(ref, detailType, isOwner) {
- return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
- 'canEdit' : '';
- }
-
- _handleEditRevision(e) {
- this._revisedRef = e.model.get('item.revision');
- this._isEditing = true;
- }
-
- _handleCancelRevision() {
- this._isEditing = false;
- }
-
- _handleSaveRevision(e) {
- this._setRepoHead(this._repo, this._revisedRef, e);
- }
-
- _setRepoHead(repo, ref, e) {
- return this.$.restAPI.setRepoHead(repo, ref).then(res => {
- if (res.status < 400) {
- this._isEditing = false;
- e.model.set('item.revision', ref);
- // This is needed to refresh _items property with fresh data,
- // specifically can_delete from the json response.
- this._getItems(
- this._filter, this._repo, this._itemsPerPage,
- this._offset, this.detailType);
- }
- });
- }
-
- _computeItemName(detailType) {
- if (detailType === DETAIL_TYPES.BRANCHES) {
- return 'Branch';
- } else if (detailType === DETAIL_TYPES.TAGS) {
- return 'Tag';
- }
- }
-
- _handleDeleteItemConfirm() {
- this.$.overlay.close();
- if (this.detailType === DETAIL_TYPES.BRANCHES) {
- return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
- .then(itemDeleted => {
- if (itemDeleted.status === 204) {
- this._getItems(
- this._filter, this._repo, this._itemsPerPage,
- this._offset, this.detailType);
- }
- });
- } else if (this.detailType === DETAIL_TYPES.TAGS) {
- return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
- .then(itemDeleted => {
- if (itemDeleted.status === 204) {
- this._getItems(
- this._filter, this._repo, this._itemsPerPage,
- this._offset, this.detailType);
- }
- });
- }
- }
-
- _handleConfirmDialogCancel() {
- this.$.overlay.close();
- }
-
- _handleDeleteItem(e) {
- const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
- if (!name) { return; }
- this._refName = name;
- this.$.overlay.open();
- }
-
- _computeHideDeleteClass(owner, canDelete) {
- if (canDelete || owner) {
- return 'show';
- }
-
- return '';
- }
-
- _handleCreateItem() {
- this.$.createNewModal.handleCreateItem();
- this._handleCloseCreate();
- }
-
- _handleCloseCreate() {
- this.$.createOverlay.close();
- }
-
- _handleCreateClicked() {
- this.$.createOverlay.open();
- }
-
- _hideIfBranch(type) {
- if (type === DETAIL_TYPES.BRANCHES) {
- return 'hideItem';
- }
-
- return '';
- }
-
- _computeHideTagger(tagger) {
- return tagger ? '' : 'hide';
- }
-}
-
-customElements.define(GrRepoDetailList.is, GrRepoDetailList);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
new file mode 100644
index 0000000..828d447
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -0,0 +1,381 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-detail-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {encodeURL} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import {
+ RepoName,
+ ProjectInfo,
+ BranchInfo,
+ GitRef,
+ TagInfo,
+ GitPersonInfo,
+} from '../../../types/common';
+import {AppElementRepoParams} from '../../gr-app-types';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+
+const PGP_START = '-----BEGIN PGP SIGNATURE-----';
+
+export interface GrRepoDetailList {
+ $: {
+ restAPI: RestApiService & Element;
+ overlay: GrOverlay;
+ createOverlay: GrOverlay;
+ createNewModal: GrCreatePointerDialog;
+ };
+}
+@customElement('gr-repo-detail-list')
+export class GrRepoDetailList extends ListViewMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementRepoParams;
+
+ @property({type: String})
+ detailType?: RepoDetailView;
+
+ @property({type: Boolean})
+ _editing = false;
+
+ @property({type: Boolean})
+ _isOwner = false;
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Number})
+ _offset?: number;
+
+ @property({type: String})
+ _repo?: RepoName;
+
+ @property({type: Array})
+ _items?: BranchInfo[] | TagInfo[];
+
+ @property({type: Array, computed: 'computeShownItems(_items)'})
+ _shownItems?: BranchInfo[] | TagInfo[];
+
+ @property({type: Number})
+ _itemsPerPage = 25;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _filter?: string;
+
+ @property({type: String})
+ _refName?: GitRef;
+
+ @property({type: Boolean})
+ _hasNewItemName?: boolean;
+
+ @property({type: Boolean})
+ _isEditing?: boolean;
+
+ @property({type: String})
+ _revisedRef?: GitRef;
+
+ _determineIfOwner(repo: RepoName) {
+ return this.$.restAPI
+ .getRepoAccess(repo)
+ .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
+ }
+
+ _paramsChanged(params?: AppElementRepoParams) {
+ if (!params?.repo) {
+ return Promise.reject(new Error('undefined repo'));
+ }
+
+ this._repo = params.repo;
+
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ if (loggedIn && this._repo) {
+ this._determineIfOwner(this._repo);
+ }
+ });
+
+ this.detailType = params.detail;
+
+ this._filter = this.getFilterValue(params);
+ this._offset = this.getOffsetValue(params);
+ if (!this.detailType)
+ return Promise.reject(new Error('undefined detailType'));
+
+ return this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType
+ );
+ }
+
+ // TODO(TS) Move this to object for easier read, understand.
+ _getItems(
+ filter: string | undefined,
+ repo: RepoName | undefined,
+ itemsPerPage: number,
+ offset: number | undefined,
+ detailType: string
+ ) {
+ if (filter === undefined || !repo || offset === undefined) {
+ return Promise.reject(new Error('filter or repo or offset undefined'));
+ }
+ this._loading = true;
+ this._items = [];
+ flush();
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+ if (detailType === RepoDetailView.BRANCHES) {
+ return this.$.restAPI
+ .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
+ .then(items => {
+ if (!items) {
+ return;
+ }
+ this._items = items;
+ this._loading = false;
+ });
+ } else if (detailType === RepoDetailView.TAGS) {
+ return this.$.restAPI
+ .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
+ .then(items => {
+ if (!items) {
+ return;
+ }
+ this._items = items;
+ this._loading = false;
+ });
+ }
+ return Promise.reject(new Error('unknown detail type'));
+ }
+
+ _getPath(repo: RepoName) {
+ return `/admin/repos/${encodeURL(repo, false)},${this.detailType}`;
+ }
+
+ _computeWeblink(repo: ProjectInfo) {
+ if (!repo.web_links) {
+ return '';
+ }
+ const webLinks = repo.web_links;
+ return webLinks.length ? webLinks : null;
+ }
+
+ _computeMessage(message?: string) {
+ if (!message) {
+ return;
+ }
+ // Strip PGP info.
+ return message.split(PGP_START)[0];
+ }
+
+ _stripRefs(item: GitRef, detailType?: string) {
+ if (detailType === RepoDetailView.BRANCHES) {
+ return item.replace('refs/heads/', '');
+ } else if (detailType === RepoDetailView.TAGS) {
+ return item.replace('refs/tags/', '');
+ }
+ throw new Error('unknown detailType');
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _computeEditingClass(isEditing: boolean) {
+ return isEditing ? 'editing' : '';
+ }
+
+ _computeCanEditClass(ref: GitRef, detailType: string, isOwner: boolean) {
+ return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
+ ? 'canEdit'
+ : '';
+ }
+
+ _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
+ this._revisedRef = (e.model.get('item.revision') as unknown) as GitRef;
+ this._isEditing = true;
+ }
+
+ _handleCancelRevision() {
+ this._isEditing = false;
+ }
+
+ _handleSaveRevision(e: PolymerDomRepeatEvent<GitRef>) {
+ if (this._revisedRef && this._repo)
+ this._setRepoHead(this._repo, this._revisedRef, e);
+ }
+
+ _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
+ return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+ if (res.status < 400) {
+ this._isEditing = false;
+ e.model.set('item.revision', ref);
+ // This is needed to refresh _items property with fresh data,
+ // specifically can_delete from the json response.
+ this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType!
+ );
+ }
+ });
+ }
+
+ _computeItemName(detailType: string) {
+ if (detailType === RepoDetailView.BRANCHES) {
+ return 'Branch';
+ } else if (detailType === RepoDetailView.TAGS) {
+ return 'Tag';
+ }
+ throw new Error('unknown detailType');
+ }
+
+ _handleDeleteItemConfirm() {
+ this.$.overlay.close();
+ if (!this._repo || !this._refName) {
+ return Promise.reject(new Error('undefined repo or refName'));
+ }
+ if (this.detailType === RepoDetailView.BRANCHES) {
+ return this.$.restAPI
+ .deleteRepoBranches(this._repo, this._refName)
+ .then(itemDeleted => {
+ if (itemDeleted.status === 204) {
+ this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType!
+ );
+ }
+ });
+ } else if (this.detailType === RepoDetailView.TAGS) {
+ return this.$.restAPI
+ .deleteRepoTags(this._repo, this._refName)
+ .then(itemDeleted => {
+ if (itemDeleted.status === 204) {
+ this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType!
+ );
+ }
+ });
+ }
+ return Promise.reject(new Error('unknown detail type'));
+ }
+
+ _handleConfirmDialogCancel() {
+ this.$.overlay.close();
+ }
+
+ _handleDeleteItem(e: PolymerDomRepeatEvent<GitRef>) {
+ const name = this._stripRefs(
+ e.model.get('item.ref'),
+ this.detailType
+ ) as GitRef;
+ if (!name) {
+ return;
+ }
+ this._refName = name;
+ this.$.overlay.open();
+ }
+
+ _computeHideDeleteClass(owner: boolean, canDelete: boolean) {
+ if (canDelete || owner) {
+ return 'show';
+ }
+
+ return '';
+ }
+
+ _handleCreateItem() {
+ this.$.createNewModal.handleCreateItem();
+ this._handleCloseCreate();
+ }
+
+ _handleCloseCreate() {
+ this.$.createOverlay.close();
+ }
+
+ _handleCreateClicked() {
+ this.$.createOverlay.open();
+ }
+
+ _hideIfBranch(type: string) {
+ if (type === RepoDetailView.BRANCHES) {
+ return 'hideItem';
+ }
+
+ return '';
+ }
+
+ _computeHideTagger(tagger: GitPersonInfo) {
+ return tagger ? '' : 'hide';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-detail-list': GrRepoDetailList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 7f6ba08..f86f613 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -17,6 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-repo-detail-list.js';
+import 'lodash/lodash.js';
import {page} from '../../../utils/page-wrapper-utils.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
deleted file mode 100644
index 9cbfda0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ /dev/null
@@ -1,382 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-download-commands/gr-download-commands.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/shared-styles.js';
-import '../gr-repo-plugin-config/gr-repo-plugin-config.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const STATES = {
- active: {value: 'ACTIVE', label: 'Active'},
- readOnly: {value: 'READ_ONLY', label: 'Read Only'},
- hidden: {value: 'HIDDEN', label: 'Hidden'},
-};
-
-const SUBMIT_TYPES = {
- // Exclude INHERIT, which is handled specially.
- mergeIfNecessary: {
- value: 'MERGE_IF_NECESSARY',
- label: 'Merge if necessary',
- },
- fastForwardOnly: {
- value: 'FAST_FORWARD_ONLY',
- label: 'Fast forward only',
- },
- rebaseAlways: {
- value: 'REBASE_ALWAYS',
- label: 'Rebase Always',
- },
- rebaseIfNecessary: {
- value: 'REBASE_IF_NECESSARY',
- label: 'Rebase if necessary',
- },
- mergeAlways: {
- value: 'MERGE_ALWAYS',
- label: 'Merge always',
- },
- cherryPick: {
- value: 'CHERRY_PICK',
- label: 'Cherry pick',
- },
-};
-
-/**
- * @extends PolymerElement
- */
-class GrRepo extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- // Notes for future TS conversion:
- // _repoConfig: ConfigInfo
- // _pluginData: PluginData[], can't be null, PluginData from gr-repo-plugin-config.ts
-
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo'; }
-
- static get properties() {
- return {
- params: Object,
- repo: String,
-
- _configChanged: {
- type: Boolean,
- value: false,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- observer: '_loggedInChanged',
- },
- /** @type {?} */
- _repoConfig: Object,
- /** @type {?} */
- _pluginData: {
- type: Array,
- computed: '_computePluginData(_repoConfig.plugin_config.*)',
- },
- _readOnly: {
- type: Boolean,
- value: true,
- },
- _states: {
- type: Array,
- value() {
- return Object.values(STATES);
- },
- },
- _submitTypes: {
- type: Array,
- value() {
- return Object.values(SUBMIT_TYPES);
- },
- },
- _schemes: {
- type: Array,
- value() { return []; },
- computed: '_computeSchemes(_schemesObj)',
- observer: '_schemesChanged',
- },
- _selectedCommand: {
- type: String,
- value: 'Clone',
- },
- _selectedScheme: String,
- _schemesObj: Object,
- };
- }
-
- static get observers() {
- return [
- '_handleConfigChanged(_repoConfig.*)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadRepo();
-
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: this.repo},
- composed: true, bubbles: true,
- }));
- }
-
- _computePluginData(configRecord) {
- if (!configRecord ||
- !configRecord.base) { return []; }
-
- const pluginConfig = configRecord.base;
- return Object.keys(pluginConfig)
- .map(name => { return {name, config: pluginConfig[name]}; });
- }
-
- _loadRepo() {
- if (!this.repo) { return Promise.resolve(); }
-
- const promises = [];
-
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- promises.push(this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- if (loggedIn) {
- this.$.restAPI.getRepoAccess(this.repo).then(access => {
- if (!access) { return Promise.resolve(); }
-
- // If the user is not an owner, is_owner is not a property.
- this._readOnly = !access[this.repo].is_owner;
- });
- }
- }));
-
- promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
- .then(config => {
- if (!config) { return Promise.resolve(); }
-
- if (config.default_submit_type) {
- // The gr-select is bound to submit_type, which needs to be the
- // *configured* submit type. When default_submit_type is
- // present, the server reports the *effective* submit type in
- // submit_type, so we need to overwrite it before storing the
- // config in this.
- config.submit_type =
- config.default_submit_type.configured_value;
- }
- if (!config.state) {
- config.state = STATES.active.value;
- }
- this._repoConfig = config;
- this._loading = false;
- }));
-
- promises.push(this.$.restAPI.getConfig().then(config => {
- if (!config) { return Promise.resolve(); }
-
- this._schemesObj = config.download.schemes;
- }));
-
- return Promise.all(promises);
- }
-
- _computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- _computeHideClass(arr) {
- return !arr || !arr.length ? 'hide' : '';
- }
-
- _loggedInChanged(_loggedIn) {
- if (!_loggedIn) { return; }
- this.$.restAPI.getPreferences().then(prefs => {
- if (prefs.download_scheme) {
- // Note (issue 5180): normalize the download scheme with lower-case.
- this._selectedScheme = prefs.download_scheme.toLowerCase();
- }
- });
- }
-
- _formatBooleanSelect(item) {
- if (!item) { return; }
- let inheritLabel = 'Inherit';
- if (!(item.inherited_value === undefined)) {
- inheritLabel = `Inherit (${item.inherited_value})`;
- }
- return [
- {
- label: inheritLabel,
- value: 'INHERIT',
- },
- {
- label: 'True',
- value: 'TRUE',
- }, {
- label: 'False',
- value: 'FALSE',
- },
- ];
- }
-
- _formatSubmitTypeSelect(projectConfig) {
- if (!projectConfig) { return; }
- const allValues = Object.values(SUBMIT_TYPES);
- const type = projectConfig.default_submit_type;
- if (!type) {
- // Server is too old to report default_submit_type, so assume INHERIT
- // is not a valid value.
- return allValues;
- }
-
- let inheritLabel = 'Inherit';
- if (type.inherited_value) {
- let inherited = type.inherited_value;
- for (const val of allValues) {
- if (val.value === type.inherited_value) {
- inherited = val.label;
- break;
- }
- }
- inheritLabel = `Inherit (${inherited})`;
- }
- return [
- {
- label: inheritLabel,
- value: 'INHERIT',
- },
- ...allValues,
- ];
- }
-
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _formatRepoConfigForSave(repoConfig) {
- const configInputObj = {};
- for (const key in repoConfig) {
- if (repoConfig.hasOwnProperty(key)) {
- if (key === 'default_submit_type') {
- // default_submit_type is not in the input type, and the
- // configured value was already copied to submit_type by
- // _loadProject. Omit this property when saving.
- continue;
- }
- if (key === 'plugin_config') {
- configInputObj.plugin_config_values = repoConfig[key];
- } else if (typeof repoConfig[key] === 'object') {
- configInputObj[key] = repoConfig[key].configured_value;
- } else {
- configInputObj[key] = repoConfig[key];
- }
- }
- }
- return configInputObj;
- }
-
- _handleSaveRepoConfig() {
- return this.$.restAPI.saveRepoConfig(this.repo,
- this._formatRepoConfigForSave(this._repoConfig)).then(() => {
- this._configChanged = false;
- });
- }
-
- _handleConfigChanged() {
- if (this._isLoading()) { return; }
- this._configChanged = true;
- }
-
- _computeButtonDisabled(readOnly, configChanged) {
- return readOnly || !configChanged;
- }
-
- _computeHeaderClass(configChanged) {
- return configChanged ? 'edited' : '';
- }
-
- _computeSchemes(schemesObj) {
- return Object.keys(schemesObj);
- }
-
- _schemesChanged(schemes) {
- if (schemes.length === 0) { return; }
- if (!schemes.includes(this._selectedScheme)) {
- this._selectedScheme = schemes.sort()[0];
- }
- }
-
- _computeCommands(repo, schemesObj, _selectedScheme) {
- if (!schemesObj || !repo || !_selectedScheme) {
- return [];
- }
- const commands = [];
- let commandObj;
- if (schemesObj.hasOwnProperty(_selectedScheme)) {
- commandObj = schemesObj[_selectedScheme].clone_commands;
- }
- for (const title in commandObj) {
- if (!commandObj.hasOwnProperty(title)) { continue; }
- commands.push({
- title,
- command: commandObj[title]
- .replace(/\${project}/gi, encodeURI(repo))
- .replace(/\${project-base-name}/gi,
- encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
- });
- }
- return commands;
- }
-
- _computeRepositoriesClass(config) {
- return config ? 'showConfig': '';
- }
-
- _computeChangesUrl(name) {
- return GerritNav.getUrlForProjectChanges(name);
- }
-
- _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
- this._repoConfig.plugin_config[name] = config;
- this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
- }
-}
-
-customElements.define(GrRepo.is, GrRepo);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
new file mode 100644
index 0000000..101c77a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -0,0 +1,455 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '@polymer/iron-input/iron-input';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-download-commands/gr-download-commands';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+ RestApiService,
+ ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ConfigInfo,
+ RepoName,
+ InheritedBooleanInfo,
+ SchemesInfoMap,
+ ConfigInput,
+ PluginParameterToConfigParameterInfoMap,
+ PluginNameToPluginParametersMap,
+} from '../../../types/common';
+import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {ProjectState} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const STATES = {
+ active: {value: ProjectState.ACTIVE, label: 'Active'},
+ readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
+ hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+};
+
+const SUBMIT_TYPES = {
+ // Exclude INHERIT, which is handled specially.
+ mergeIfNecessary: {
+ value: 'MERGE_IF_NECESSARY',
+ label: 'Merge if necessary',
+ },
+ fastForwardOnly: {
+ value: 'FAST_FORWARD_ONLY',
+ label: 'Fast forward only',
+ },
+ rebaseAlways: {
+ value: 'REBASE_ALWAYS',
+ label: 'Rebase Always',
+ },
+ rebaseIfNecessary: {
+ value: 'REBASE_IF_NECESSARY',
+ label: 'Rebase if necessary',
+ },
+ mergeAlways: {
+ value: 'MERGE_ALWAYS',
+ label: 'Merge always',
+ },
+ cherryPick: {
+ value: 'CHERRY_PICK',
+ label: 'Cherry pick',
+ },
+};
+
+export interface GrRepo {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-repo')
+export class GrRepo extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ repo?: RepoName;
+
+ @property({type: Boolean})
+ _configChanged = false;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean, observer: '_loggedInChanged'})
+ _loggedIn = false;
+
+ @property({type: Object})
+ _repoConfig?: ConfigInfo;
+
+ @property({
+ type: Array,
+ computed: '_computePluginData(_repoConfig.plugin_config.*)',
+ })
+ _pluginData?: PluginData[];
+
+ @property({type: Boolean})
+ _readOnly = true;
+
+ @property({type: Array})
+ _states = Object.values(STATES);
+
+ @property({
+ type: Array,
+ computed: '_computeSchemes(_schemesDefault, _schemesObj)',
+ observer: '_schemesChanged',
+ })
+ _schemes: string[] = [];
+
+ // This is workaround to have _schemes with default value [],
+ // because assignment doesn't work when property has a computed attribute.
+ @property({type: Array})
+ _schemesDefault: string[] = [];
+
+ @property({type: String})
+ _selectedCommand = 'Clone';
+
+ @property({type: String})
+ _selectedScheme?: string;
+
+ @property({type: Object})
+ _schemesObj?: SchemesInfoMap;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadRepo();
+
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: this.repo},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _computePluginData(
+ configRecord: PolymerDeepPropertyChange<
+ PluginNameToPluginParametersMap,
+ PluginNameToPluginParametersMap
+ >
+ ) {
+ if (!configRecord || !configRecord.base) {
+ return [];
+ }
+
+ const pluginConfig = configRecord.base;
+ return Object.keys(pluginConfig).map(name => {
+ return {name, config: pluginConfig[name]};
+ });
+ }
+
+ _loadRepo() {
+ if (!this.repo) {
+ return Promise.resolve();
+ }
+
+ const promises = [];
+
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ promises.push(
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ if (loggedIn) {
+ const repo = this.repo;
+ if (!repo) throw new Error('undefined repo');
+ this.$.restAPI.getRepoAccess(repo).then(access => {
+ if (!access || this.repo !== repo) {
+ return;
+ }
+
+ // If the user is not an owner, is_owner is not a property.
+ this._readOnly = !access[repo].is_owner;
+ });
+ }
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+ if (!config) {
+ return;
+ }
+
+ if (config.default_submit_type) {
+ // The gr-select is bound to submit_type, which needs to be the
+ // *configured* submit type. When default_submit_type is
+ // present, the server reports the *effective* submit type in
+ // submit_type, so we need to overwrite it before storing the
+ // config in this.
+ config.submit_type = config.default_submit_type.configured_value;
+ }
+ if (!config.state) {
+ config.state = STATES.active.value;
+ }
+ this._repoConfig = config;
+ this._loading = false;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getConfig().then(config => {
+ if (!config) {
+ return;
+ }
+
+ this._schemesObj = config.download.schemes;
+ })
+ );
+
+ return Promise.all(promises);
+ }
+
+ _computeLoadingClass(loading: boolean) {
+ return loading ? 'loading' : '';
+ }
+
+ _computeHideClass(arr?: PluginData[] | string[]) {
+ return !arr || !arr.length ? 'hide' : '';
+ }
+
+ _loggedInChanged(_loggedIn?: boolean) {
+ if (!_loggedIn) {
+ return;
+ }
+ this.$.restAPI.getPreferences().then(prefs => {
+ if (prefs?.download_scheme) {
+ // Note (issue 5180): normalize the download scheme with lower-case.
+ this._selectedScheme = prefs.download_scheme.toLowerCase();
+ }
+ });
+ }
+
+ _formatBooleanSelect(item: InheritedBooleanInfo) {
+ if (!item) {
+ return;
+ }
+ let inheritLabel = 'Inherit';
+ if (!(item.inherited_value === undefined)) {
+ inheritLabel = `Inherit (${item.inherited_value})`;
+ }
+ return [
+ {
+ label: inheritLabel,
+ value: 'INHERIT',
+ },
+ {
+ label: 'True',
+ value: 'TRUE',
+ },
+ {
+ label: 'False',
+ value: 'FALSE',
+ },
+ ];
+ }
+
+ _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
+ if (!projectConfig) {
+ return;
+ }
+ const allValues = Object.values(SUBMIT_TYPES);
+ const type = projectConfig.default_submit_type;
+ if (!type) {
+ // Server is too old to report default_submit_type, so assume INHERIT
+ // is not a valid value.
+ return allValues;
+ }
+
+ let inheritLabel = 'Inherit';
+ if (type.inherited_value) {
+ inheritLabel = `Inherit (${type.inherited_value})`;
+ for (const val of allValues) {
+ if (val.value === type.inherited_value) {
+ inheritLabel = `Inherit (${val.label})`;
+ break;
+ }
+ }
+ }
+ return [
+ {
+ label: inheritLabel,
+ value: 'INHERIT',
+ },
+ ...allValues,
+ ];
+ }
+
+ _isLoading() {
+ return this._loading || this._loading === undefined;
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+ const configInputObj: ConfigInput = {};
+ for (const configKey of Object.keys(repoConfig)) {
+ const key = configKey as keyof ConfigInfo;
+ if (key === 'default_submit_type') {
+ // default_submit_type is not in the input type, and the
+ // configured value was already copied to submit_type by
+ // _loadProject. Omit this property when saving.
+ continue;
+ }
+ if (key === 'plugin_config') {
+ configInputObj.plugin_config_values = repoConfig.plugin_config;
+ } else if (typeof repoConfig[key] === 'object') {
+ const repoConfigObj: any = repoConfig[key];
+ if (repoConfigObj.configured_value) {
+ configInputObj[key as keyof ConfigInput] =
+ repoConfigObj.configured_value;
+ }
+ } else {
+ configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
+ }
+ }
+ return configInputObj;
+ }
+
+ _handleSaveRepoConfig() {
+ if (!this._repoConfig || !this.repo)
+ return Promise.reject(new Error('undefined repoConfig or repo'));
+ return this.$.restAPI
+ .saveRepoConfig(
+ this.repo,
+ this._formatRepoConfigForSave(this._repoConfig)
+ )
+ .then(() => {
+ this._configChanged = false;
+ });
+ }
+
+ @observe('_repoConfig.*')
+ _handleConfigChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._configChanged = true;
+ }
+
+ _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
+ return readOnly || !configChanged;
+ }
+
+ _computeHeaderClass(configChanged: boolean) {
+ return configChanged ? 'edited' : '';
+ }
+
+ _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
+ return !schemesObj ? schemesDefault : Object.keys(schemesObj);
+ }
+
+ _schemesChanged(schemes: string[]) {
+ if (schemes.length === 0) {
+ return;
+ }
+ if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
+ this._selectedScheme = schemes.sort()[0];
+ }
+ }
+
+ _computeCommands(
+ repo?: RepoName,
+ schemesObj?: SchemesInfoMap,
+ _selectedScheme?: string
+ ) {
+ if (!schemesObj || !repo || !_selectedScheme) {
+ return [];
+ }
+ const commands = [];
+ let commandObj: {[title: string]: string} = {};
+ if (hasOwnProperty(schemesObj, _selectedScheme)) {
+ commandObj = schemesObj[_selectedScheme].clone_commands;
+ }
+ for (const title in commandObj) {
+ if (!hasOwnProperty(commandObj, title)) {
+ continue;
+ }
+ commands.push({
+ title,
+ command: commandObj[title]
+ .replace(/\${project}/gi, encodeURI(repo))
+ .replace(
+ /\${project-base-name}/gi,
+ encodeURI(repo.substring(repo.lastIndexOf('/') + 1))
+ ),
+ });
+ }
+ return commands;
+ }
+
+ _computeRepositoriesClass(config: InheritedBooleanInfo) {
+ return config ? 'showConfig' : '';
+ }
+
+ _computeChangesUrl(name: RepoName) {
+ return GerritNav.getUrlForProjectChanges(name);
+ }
+
+ _handlePluginConfigChanged({
+ detail: {name, config, notifyPath},
+ }: {
+ detail: {
+ name: string;
+ config: PluginParameterToConfigParameterInfoMap;
+ notifyPath: string;
+ };
+ }) {
+ if (this._repoConfig?.plugin_config) {
+ this._repoConfig.plugin_config[name] = config;
+ this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo': GrRepo;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
deleted file mode 100644
index 2230c38..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ /dev/null
@@ -1,441 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../styles/gr-change-list-styles.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list-item/gr-change-list-item.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-list_html.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const NUMBER_FIXED_COLUMNS = 3;
-const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-const MAX_SHORTCUT_CHARS = 5;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeList extends ChangeTableMixin(
- KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement)))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-list'; }
- /**
- * Fired when next page key shortcut was pressed.
- *
- * @event next-page
- */
-
- /**
- * Fired when previous page key shortcut was pressed.
- *
- * @event previous-page
- */
-
- static get properties() {
- return {
- /**
- * The logged-in user's account, or an empty object if no user is logged
- * in.
- */
- account: {
- type: Object,
- value: null,
- },
- /**
- * An array of ChangeInfo objects to render.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
- */
- changes: {
- type: Array,
- observer: '_changesChanged',
- },
- /**
- * ChangeInfo objects grouped into arrays. The sections and changes
- * properties should not be used together.
- *
- * @type {!Array<{
- * name: string,
- * query: string,
- * results: !Array<!Object>
- * }>}
- */
- sections: {
- type: Array,
- value() { return []; },
- },
- labelNames: {
- type: Array,
- computed: '_computeLabelNames(sections)',
- },
- _dynamicHeaderEndpoints: {
- type: Array,
- },
- selectedIndex: {
- type: Number,
- notify: true,
- },
- showNumber: Boolean, // No default value to prevent flickering.
- showStar: {
- type: Boolean,
- value: false,
- },
- showReviewedState: {
- type: Boolean,
- value: false,
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- changeTableColumns: Array,
- visibleChangeTableColumns: Array,
- preferences: Object,
- _config: Object,
- };
- }
-
- static get observers() {
- return [
- '_sectionsChanged(sections.*)',
- '_computePreferences(account, preferences, _config)',
- ];
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
- [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
- [Shortcut.NEXT_PAGE]: '_nextPage',
- [Shortcut.PREV_PAGE]: '_prevPage',
- [Shortcut.OPEN_CHANGE]: '_openChange',
- [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
- [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
- [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
- };
- }
-
- constructor() {
- super();
- this.flagsService = appContext.flagsService;
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('keydown',
- e => this._scopedKeydownHandler(e));
- }
-
- /** @override */
- ready() {
- super.ready();
- this.$.restAPI.getConfig().then(config => {
- this._config = config;
- });
- }
-
- /** @override */
- attached() {
- super.attached();
- getPluginLoader().awaitPluginsLoaded()
- .then(() => {
- this._dynamicHeaderEndpoints = getPluginEndpoints().
- getDynamicEndpoints('change-list-header');
- });
- }
-
- /**
- * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
- * events must be scoped to a component level (e.g. `enter`) in order to not
- * override native browser functionality.
- *
- * Context: Issue 7294
- */
- _scopedKeydownHandler(e) {
- if (e.keyCode === 13) {
- // Enter.
- this._openChange(e);
- }
- }
-
- _lowerCase(column) {
- return column.toLowerCase();
- }
-
- _computePreferences(account, preferences, config) {
- // Polymer 2: check for undefined
- if ([account, preferences, config].includes(undefined)) {
- return;
- }
-
- this.changeTableColumns = this.columnNames;
- this.showNumber = false;
- this.visibleChangeTableColumns = this.getEnabledColumns(this.columnNames,
- config, this.flagsService.enabledExperiments);
-
- if (account) {
- this.showNumber = !!(preferences &&
- preferences.legacycid_in_change_table);
- if (preferences.change_table &&
- preferences.change_table.length > 0) {
- const prefColumns = this.getVisibleColumns(preferences.change_table);
- this.visibleChangeTableColumns = this.getEnabledColumns(prefColumns,
- config, this.flagsService.enabledExperiments);
- }
- }
- }
-
- _computeColspan(changeTableColumns, labelNames) {
- if (!changeTableColumns || !labelNames) return;
- return changeTableColumns.length + labelNames.length +
- NUMBER_FIXED_COLUMNS;
- }
-
- _computeLabelNames(sections) {
- if (!sections) { return []; }
- let labels = [];
- const nonExistingLabel = function(item) {
- return !labels.includes(item);
- };
- for (const section of sections) {
- if (!section.results) { continue; }
- for (const change of section.results) {
- if (!change.labels) { continue; }
- const currentLabels = Object.keys(change.labels);
- labels = labels.concat(currentLabels.filter(nonExistingLabel));
- }
- }
- return labels.sort();
- }
-
- _computeLabelShortcut(labelName) {
- if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
- labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
- }
- return labelName.split('-')
- .reduce((a, i) => {
- if (!i) { return a; }
- return a + i[0].toUpperCase();
- }, '')
- .slice(0, MAX_SHORTCUT_CHARS);
- }
-
- _changesChanged(changes) {
- this.sections = changes ? [{results: changes}] : [];
- }
-
- _processQuery(query) {
- let tokens = query.split(' ');
- const invalidTokens = ['limit:', 'age:', '-age:'];
- tokens = tokens.filter(token => !invalidTokens
- .some(invalidToken => token.startsWith(invalidToken)));
- return tokens.join(' ');
- }
-
- _sectionHref(query) {
- return GerritNav.getUrlForSearchQuery(this._processQuery(query));
- }
-
- /**
- * Maps an index local to a particular section to the absolute index
- * across all the changes on the page.
- *
- * @param {number} sectionIndex index of section
- * @param {number} localIndex index of row within section
- * @return {number} absolute index of row in the aggregate dashboard
- */
- _computeItemAbsoluteIndex(sectionIndex, localIndex) {
- let idx = 0;
- for (let i = 0; i < sectionIndex; i++) {
- idx += this.sections[i].results.length;
- }
- return idx + localIndex;
- }
-
- _computeItemSelected(sectionIndex, index, selectedIndex) {
- const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
- return idx == selectedIndex;
- }
-
- _computeTabIndex(sectionIndex, index, selectedIndex) {
- return this._computeItemSelected(sectionIndex, index, selectedIndex)
- ? 0 : undefined;
- }
-
- _computeItemNeedsReview(account, change, showReviewedState, config) {
- const isAttentionSetEnabled =
- !!config && !!config.change && config.change.enable_attention_set;
- return !isAttentionSetEnabled && showReviewedState && !change.reviewed &&
- !change.work_in_progress &&
- changeIsOpen(change) &&
- (!account || account._account_id != change.owner._account_id);
- }
-
- _computeItemHighlight(account, change) {
- // Do not show the assignee highlight if the change is not open.
- if (!change ||!change.assignee ||
- !account ||
- CLOSED_STATUS.indexOf(change.status) !== -1) {
- return false;
- }
- return account._account_id === change.assignee._account_id;
- }
-
- _nextChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.next();
- }
-
- _prevChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.previous();
- }
-
- _openChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- GerritNav.navigateToChange(this._changeForIndex(this.selectedIndex));
- }
-
- _nextPage(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
- return;
- }
-
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('next-page', {
- composed: true, bubbles: true,
- }));
- }
-
- _prevPage(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
- return;
- }
-
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('previous-page', {
- composed: true, bubbles: true,
- }));
- }
-
- _toggleChangeReviewed(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._toggleReviewedForIndex(this.selectedIndex);
- }
-
- _toggleReviewedForIndex(index) {
- const changeEls = this._getListItems();
- if (index >= changeEls.length || !changeEls[index]) {
- return;
- }
-
- const changeEl = changeEls[index];
- changeEl.toggleReviewed();
- }
-
- _refreshChangeList(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this._reloadWindow();
- }
-
- _reloadWindow() {
- window.location.reload();
- }
-
- _toggleChangeStar(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._toggleStarForIndex(this.selectedIndex);
- }
-
- _toggleStarForIndex(index) {
- const changeEls = this._getListItems();
- if (index >= changeEls.length || !changeEls[index]) {
- return;
- }
-
- const changeEl = changeEls[index];
- changeEl.shadowRoot
- .querySelector('gr-change-star').toggleStar();
- }
-
- _changeForIndex(index) {
- const changeEls = this._getListItems();
- if (index < changeEls.length && changeEls[index]) {
- return changeEls[index].change;
- }
- return null;
- }
-
- _getListItems() {
- return Array.from(
- this.root.querySelectorAll('gr-change-list-item'));
- }
-
- _sectionsChanged() {
- // Flush DOM operations so that the list item elements will be loaded.
- afterNextRender(this, () => {
- this.$.cursor.stops = this._getListItems();
- this.$.cursor.moveToStart();
- });
- }
-
- _getSpecialEmptySlot(section) {
- if (section.isOutgoing) return 'empty-outgoing';
- if (section.name === 'Your Turn') return 'empty-your-turn';
- return '';
- }
-
- _isEmpty(section) {
- return !section.results.length;
- }
-}
-
-customElements.define(GrChangeList.is, GrChangeList);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
new file mode 100644
index 0000000..a05ccf4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -0,0 +1,508 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../styles/gr-change-list-styles';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list-item/gr-change-list-item';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list_html';
+import {appContext} from '../../../services/app-context';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+ CustomKeyboardEvent,
+ Modifier,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+ GerritNav,
+ DashboardSection,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ServerInfo,
+ PreferencesInput,
+} from '../../../types/common';
+
+const NUMBER_FIXED_COLUMNS = 3;
+const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
+
+export interface ChangeListSection {
+ results: ChangeInfo[];
+}
+export interface GrChangeList {
+ $: {
+ restAPI: RestApiService & Element;
+ cursor: GrCursorManager;
+ };
+}
+@customElement('gr-change-list')
+export class GrChangeList extends ChangeTableMixin(
+ KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+ )
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when next page key shortcut was pressed.
+ *
+ * @event next-page
+ */
+
+ /**
+ * Fired when previous page key shortcut was pressed.
+ *
+ * @event previous-page
+ */
+
+ /**
+ * The logged-in user's account, or an empty object if no user is logged
+ * in.
+ */
+ @property({type: Object})
+ account: AccountInfo | undefined = undefined;
+
+ @property({type: Array, observer: '_changesChanged'})
+ changes?: ChangeInfo[];
+
+ /**
+ * ChangeInfo objects grouped into arrays. The sections and changes
+ * properties should not be used together.
+ */
+ @property({type: Array})
+ sections: ChangeListSection[] = [];
+
+ @property({type: Array, computed: '_computeLabelNames(sections)'})
+ labelNames?: string[];
+
+ @property({type: Array})
+ _dynamicHeaderEndpoints?: string[];
+
+ @property({type: Number, notify: true})
+ selectedIndex?: number;
+
+ @property({type: Boolean})
+ showNumber?: boolean; // No default value to prevent flickering.
+
+ @property({type: Boolean})
+ showStar = false;
+
+ @property({type: Boolean})
+ showReviewedState = false;
+
+ @property({type: Object})
+ keyEventTarget: HTMLElement = document.body;
+
+ @property({type: Array})
+ changeTableColumns?: string[];
+
+ @property({type: Array})
+ visibleChangeTableColumns?: string[];
+
+ @property({type: Object})
+ preferences?: PreferencesInput;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ flagsService = appContext.flagsService;
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+ [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+ [Shortcut.NEXT_PAGE]: '_nextPage',
+ [Shortcut.PREV_PAGE]: '_prevPage',
+ [Shortcut.OPEN_CHANGE]: '_openChange',
+ [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+ [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+ [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+ };
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this.$.restAPI.getConfig().then(config => {
+ this._config = config;
+ });
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-list-header'
+ );
+ });
+ }
+
+ /**
+ * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+ * events must be scoped to a component level (e.g. `enter`) in order to not
+ * override native browser functionality.
+ *
+ * Context: Issue 7294
+ */
+ _scopedKeydownHandler(e: KeyboardEvent) {
+ if (e.keyCode === 13) {
+ // Enter.
+ this._openChange((e as unknown) as CustomKeyboardEvent);
+ }
+ }
+
+ _lowerCase(column: string) {
+ return column.toLowerCase();
+ }
+
+ @observe('account', 'preferences', '_config')
+ _computePreferences(
+ account?: AccountInfo,
+ preferences?: PreferencesInput,
+ config?: ServerInfo
+ ) {
+ if (!config) {
+ return;
+ }
+
+ this.changeTableColumns = this.columnNames;
+ this.showNumber = false;
+ this.visibleChangeTableColumns = this.getEnabledColumns(
+ this.columnNames,
+ config,
+ this.flagsService.enabledExperiments
+ );
+
+ if (account && preferences) {
+ this.showNumber = !!(
+ preferences && preferences.legacycid_in_change_table
+ );
+ if (preferences.change_table && preferences.change_table.length > 0) {
+ const prefColumns = this.getVisibleColumns(preferences.change_table);
+ this.visibleChangeTableColumns = this.getEnabledColumns(
+ prefColumns,
+ config,
+ this.flagsService.enabledExperiments
+ );
+ }
+ }
+ }
+
+ _computeColspan(changeTableColumns: string[], labelNames: string[]) {
+ if (!changeTableColumns || !labelNames) return;
+ return changeTableColumns.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+ }
+
+ _computeLabelNames(sections: ChangeListSection[]) {
+ if (!sections) {
+ return [];
+ }
+ let labels: string[] = [];
+ const nonExistingLabel = function (item: string) {
+ return !labels.includes(item);
+ };
+ for (const section of sections) {
+ if (!section.results) {
+ continue;
+ }
+ for (const change of section.results) {
+ if (!change.labels) {
+ continue;
+ }
+ const currentLabels = Object.keys(change.labels);
+ labels = labels.concat(currentLabels.filter(nonExistingLabel));
+ }
+ }
+ return labels.sort();
+ }
+
+ _computeLabelShortcut(labelName: string) {
+ if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+ labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+ }
+ return labelName
+ .split('-')
+ .reduce((a, i) => {
+ if (!i) {
+ return a;
+ }
+ return a + i[0].toUpperCase();
+ }, '')
+ .slice(0, MAX_SHORTCUT_CHARS);
+ }
+
+ _changesChanged(changes: ChangeInfo[]) {
+ this.sections = changes ? [{results: changes}] : [];
+ }
+
+ _processQuery(query: string) {
+ let tokens = query.split(' ');
+ const invalidTokens = ['limit:', 'age:', '-age:'];
+ tokens = tokens.filter(
+ token =>
+ !invalidTokens.some(invalidToken => token.startsWith(invalidToken))
+ );
+ return tokens.join(' ');
+ }
+
+ _sectionHref(query: string) {
+ return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+ }
+
+ /**
+ * Maps an index local to a particular section to the absolute index
+ * across all the changes on the page.
+ *
+ * @param sectionIndex index of section
+ * @param localIndex index of row within section
+ * @return absolute index of row in the aggregate dashboard
+ */
+ _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
+ let idx = 0;
+ for (let i = 0; i < sectionIndex; i++) {
+ idx += this.sections[i].results.length;
+ }
+ return idx + localIndex;
+ }
+
+ _computeItemSelected(
+ sectionIndex: number,
+ index: number,
+ selectedIndex: number
+ ) {
+ const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+ return idx === selectedIndex;
+ }
+
+ _computeTabIndex(sectionIndex: number, index: number, selectedIndex: number) {
+ return this._computeItemSelected(sectionIndex, index, selectedIndex)
+ ? 0
+ : undefined;
+ }
+
+ _computeItemNeedsReview(
+ account: AccountInfo | undefined,
+ change: ChangeInfo,
+ showReviewedState: boolean,
+ config?: ServerInfo
+ ) {
+ const isAttentionSetEnabled =
+ !!config && !!config.change && config.change.enable_attention_set;
+ return (
+ !isAttentionSetEnabled &&
+ showReviewedState &&
+ !change.reviewed &&
+ !change.work_in_progress &&
+ changeIsOpen(change) &&
+ (!account || account._account_id !== change.owner._account_id)
+ );
+ }
+
+ _computeItemHighlight(account?: AccountInfo, change?: ChangeInfo) {
+ // Do not show the assignee highlight if the change is not open.
+ if (
+ !change ||
+ !change.assignee ||
+ !account ||
+ CLOSED_STATUS.indexOf(change.status) !== -1
+ ) {
+ return false;
+ }
+ return account._account_id === change.assignee._account_id;
+ }
+
+ _nextChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.cursor.next();
+ }
+
+ _prevChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.cursor.previous();
+ }
+
+ _openChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ const change = this._changeForIndex(this.selectedIndex);
+ if (change) GerritNav.navigateToChange(change);
+ }
+
+ _nextPage(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('next-page', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _prevPage(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('previous-page', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _toggleChangeReviewed(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleReviewedForIndex(this.selectedIndex);
+ }
+
+ _toggleReviewedForIndex(index?: number) {
+ const changeEls = this._getListItems();
+ if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+ return;
+ }
+
+ const changeEl = changeEls[index];
+ changeEl.toggleReviewed();
+ }
+
+ _refreshChangeList(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._reloadWindow();
+ }
+
+ _reloadWindow() {
+ window.location.reload();
+ }
+
+ _toggleChangeStar(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleStarForIndex(this.selectedIndex);
+ }
+
+ _toggleStarForIndex(index?: number) {
+ const changeEls = this._getListItems();
+ if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+ return;
+ }
+
+ const changeEl = changeEls[index];
+ const grChangeStar = changeEl?.shadowRoot?.querySelector('gr-change-star');
+ if (grChangeStar) grChangeStar.toggleStar();
+ }
+
+ _changeForIndex(index?: number) {
+ const changeEls = this._getListItems();
+ if (index !== undefined && index < changeEls.length && changeEls[index]) {
+ return changeEls[index].change;
+ }
+ return null;
+ }
+
+ _getListItems() {
+ const items = this.root?.querySelectorAll('gr-change-list-item');
+ return !items ? [] : Array.from(items);
+ }
+
+ @observe('sections.*')
+ _sectionsChanged() {
+ // Flush DOM operations so that the list item elements will be loaded.
+ afterNextRender(this, () => {
+ this.$.cursor.stops = this._getListItems();
+ this.$.cursor.moveToStart();
+ });
+ }
+
+ _getSpecialEmptySlot(section: DashboardSection) {
+ if (section.isOutgoing) return 'empty-outgoing';
+ if (section.name === 'Your Turn') return 'empty-your-turn';
+ return '';
+ }
+
+ _isEmpty(section: DashboardSection) {
+ return !section.results?.length;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list': GrChangeList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 0493966..56a16b5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -50,8 +50,8 @@
suite('test show change number not logged in', () => {
setup(() => {
element = basicFixture.instantiate();
- element.account = null;
- element.preferences = null;
+ element.account = undefined;
+ element.preferences = undefined;
element._config = {};
});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 54828ea..1be2f75 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -104,6 +104,12 @@
display: flex;
width: 100%;
}
+ gr-endpoint-decorator[name='reply-text'] {
+ flex-direction: column;
+ }
+ #textarea {
+ flex: 1;
+ }
.previewContainer gr-formatted-text {
background: var(--table-header-background-color);
padding: var(--spacing-l);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 358ebb0..c70678f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -1508,7 +1508,9 @@
.querySelector('gr-button.send'));
assert.isFalse(sendStub.called);
- element.draftCommentThreads = [{comments: [{__draft: true}]}];
+ element.draftCommentThreads = [{comments: [
+ {__draft: true, path: 'test', line: 1, patch_set: 1},
+ ]}];
flushAsynchronousOperations();
MockInteractions.tap(element.shadowRoot
@@ -1521,7 +1523,9 @@
// computed to false.
element.draftCommentThreads = [];
assert.equal(element.getFocusStops().end, element.$.cancelButton);
- element.draftCommentThreads = [{comments: [{__draft: true}]}];
+ element.draftCommentThreads = [{comments: [
+ {__draft: true, path: 'test', line: 1, patch_set: 1},
+ ]}];
assert.equal(element.getFocusStops().end, element.$.sendButton);
});
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 5016c40..19e733e 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -133,6 +133,7 @@
hideIfEmpty?: boolean;
assigneeOnly?: boolean;
isOutgoing?: boolean;
+ results?: ChangeInfo[];
}
export interface UserDashboardConfig {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index b49f522..ba37387 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -45,6 +45,7 @@
export interface HumanCommentInfoWithPath extends CommentInfo {
path: string;
__draft?: boolean;
+ __date?: Date;
}
export interface RobotCommentInfoWithPath extends RobotCommentInfo {
@@ -80,6 +81,27 @@
return !!(x as PatchSetFile).path;
}
+export function sortComments<
+ T extends CommentInfoWithPath | CommentInfoWithTwoPaths
+>(comments: T[]): T[] {
+ return comments.slice(0).sort((c1, c2) => {
+ const d1 = !!(c1 as HumanCommentInfoWithPath).__draft;
+ const d2 = !!(c2 as HumanCommentInfoWithPath).__draft;
+ if (d1 !== d2) return d1 ? 1 : -1;
+ const date1 =
+ (c1.updated && parseDate(c1.updated)) ||
+ (c1 as HumanCommentInfoWithPath).__date;
+ const date2 =
+ (c2.updated && parseDate(c2.updated)) ||
+ (c2 as HumanCommentInfoWithPath).__date;
+ const dateDiff = date1.valueOf() - date2.valueOf();
+ if (dateDiff) {
+ return dateDiff;
+ }
+ return c1.id < c2.id ? -1 : c1.id > c2.id ? 1 : 0;
+ });
+}
+
export interface CommentThread {
comments: CommentInfoWithTwoPaths[];
patchNum?: PatchSetNum;
@@ -519,7 +541,7 @@
// However, this doesn't affect the final result of computeUnresolvedNum
// This should be fixed by removing CommentInfoWithTwoPaths later
const threads = this.getCommentThreads(
- this._sortComments(comments) as CommentInfoWithTwoPaths[]
+ sortComments(comments) as CommentInfoWithTwoPaths[]
);
const unresolvedThreads = threads.filter(
@@ -533,26 +555,10 @@
getAllThreadsForChange() {
const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
- const sortedComments = this._sortComments(comments);
+ const sortedComments = sortComments(comments);
return this.getCommentThreads(sortedComments);
}
- _sortComments<T extends CommentInfoWithPath | CommentInfoWithTwoPaths>(
- comments: T[]
- ): T[] {
- return comments.slice(0).sort((c1, c2) => {
- const d1 = !!(c1 as HumanCommentInfoWithPath).__draft;
- const d2 = !!(c2 as HumanCommentInfoWithPath).__draft;
- if (d1 !== d2) return d1 ? 1 : -1;
- const dateDiff =
- parseDate(c1.updated).valueOf() - parseDate(c2.updated).valueOf();
- if (dateDiff) {
- return dateDiff;
- }
- return c1.id < c2.id ? -1 : c1.id > c2.id ? 1 : 0;
- });
- }
-
/**
* Computes all of the comments in thread format.
*
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 7693d56..02d8cfa 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -24,11 +24,11 @@
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-diff-host_html.js';
import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js';
-import {parseDate} from '../../../utils/date-util.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js';
import {appContext} from '../../../services/app-context.js';
import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util.js';
+import {sortComments} from '../gr-comment-api/gr-comment-api.js';
const MSG_EMPTY_BLAME = 'No blame information for this diff.';
@@ -692,20 +692,12 @@
}
}
- _sortComments(comments) {
- return comments.slice(0).sort((a, b) => {
- if (b.__draft && !a.__draft ) { return -1; }
- if (a.__draft && !b.__draft ) { return 1; }
- return parseDate(a.updated) - parseDate(b.updated);
- });
- }
-
/**
* @param {!Array<!Object>} comments
* @return {!Array<!Object>} Threads for the given comments.
*/
_createThreads(comments) {
- const sortedComments = this._sortComments(comments);
+ const sortedComments = sortComments(comments);
const threads = [];
for (const comment of sortedComments) {
// If the comment is in reply to another comment, find that comment's
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index cd43fdb..11539b1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -21,6 +21,7 @@
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {DiffSide} from '../gr-diff/gr-diff-utils.js';
+import {sortComments} from '../gr-comment-api/gr-comment-api.js';
const basicFixture = fixtureFromElement('gr-diff-host');
@@ -1131,7 +1132,7 @@
in_reply_to: 'sallys_confession',
},
];
- const sortedComments = element._sortComments(comments);
+ const sortedComments = sortComments(comments);
assert.equal(sortedComments[0], comments[1]);
assert.equal(sortedComments[1], comments[2]);
assert.equal(sortedComments[2], comments[0]);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index e80e646..ea26a51 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -494,6 +494,15 @@
</td>
</tr>
<tr>
+ <td>Changes requesting my attention</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Attention: <em>Your Name</em>
+ <<em>your.email@example.com</em>>"
+ </code>
+ </td>
+ </tr>
+ <tr>
<td>Changes from a specific owner</td>
<td>
<code class="queryExample">
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index 5de7a66..661da6f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -24,11 +24,11 @@
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-comment-thread_html.js';
import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {parseDate} from '../../../utils/date-util.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {appContext} from '../../../services/app-context.js';
import {SpecialFilePath} from '../../../constants/constants.js';
import {computeDisplayPath} from '../../../utils/path-list-util.js';
+import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
@@ -273,7 +273,7 @@
}
_commentsChanged() {
- this._orderedComments = this._sortedComments(this.comments);
+ this._orderedComments = sortComments(this.comments);
this.updateThreadProperties();
}
@@ -342,22 +342,6 @@
}
}
- _sortedComments(comments) {
- return comments.slice().sort((c1, c2) => {
- const c1Date = c1.__date || parseDate(c1.updated);
- const c2Date = c2.__date || parseDate(c2.updated);
- const dateCompare = c1Date - c2Date;
- // Ensure drafts are at the end. There should only be one but in edge
- // cases could be more. In the unlikely event two drafts are being
- // compared, use the typical date compare.
- if (c2.__draft && !c1.__draft ) { return -1; }
- if (c1.__draft && !c2.__draft ) { return 1; }
- if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
- // If same date, fall back to sorting by id.
- return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
- });
- }
-
_createReplyComment(content, opt_isEditing,
opt_unresolved) {
this.reporting.recordDraftInteraction();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
index a25a4c7..7c176ac 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -19,6 +19,7 @@
import './gr-comment-thread.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {SpecialFilePath} from '../../../constants/constants.js';
+import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
const basicFixture = fixtureFromElement('gr-comment-thread');
@@ -34,6 +35,9 @@
});
element = basicFixture.instantiate();
+ element.patchNum = '3';
+ element.changeNum = '1';
+ flushAsynchronousOperations();
});
test('comments are sorted correctly', () => {
@@ -66,7 +70,7 @@
updated: '2015-12-24 15:00:20.396000000',
},
];
- const results = element._sortedComments(comments);
+ const results = sortComments(comments);
assert.deepEqual(results, [
{
id: 'sally_to_dr_finklestein',
@@ -248,6 +252,8 @@
deleteDiffDraft() { return Promise.resolve({ok: true}); },
});
element = withCommentFixture.instantiate();
+ element.patchNum = '1';
+ element.changeNum = '1';
element.comments = [{
author: {
name: 'Mr. Peanutbutter',
@@ -310,6 +316,7 @@
email: 'tenn1sballchaser@aol.com',
},
id: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'is this a crossover episode!?\nIt might be!',
updated: '2015-12-08 19:48:33.843000000',
@@ -495,6 +502,7 @@
},
id: 'baf0414d_60047215',
line: 5,
+ path: 'test',
message: 'is this a crossover episode!?',
updated: '2015-12-08 19:48:33.843000000',
__draft: true,
@@ -519,6 +527,7 @@
email: 'tenn1sballchaser@aol.com',
},
id: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'is this a crossover episode!?',
updated: '2015-12-08 19:48:31.843000000',
@@ -530,6 +539,7 @@
},
__draftID: '1',
in_reply_to: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'yes',
updated: '2015-12-08 19:48:32.843000000',
@@ -543,6 +553,7 @@
},
__draftID: '2',
in_reply_to: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'no',
updated: '2015-12-08 19:48:33.843000000',
@@ -823,6 +834,8 @@
deleteDiffDraft() { return Promise.resolve({ok: true}); },
});
element = withCommentFixture.instantiate();
+ element.patchNum = '1';
+ element.changeNum = '1';
element.comments = [{
author: {
name: 'Mr. Peanutbutter',
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 d240f2c..23e024b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -159,10 +159,10 @@
*/
@property({type: Number})
- changeNum!: number;
+ changeNum?: number;
@property({type: Object, notify: true, observer: '_commentChanged'})
- comment!: Comment | RobotComment;
+ comment?: Comment | RobotComment;
@property({type: Array})
comments?: (Comment | RobotComment)[];
@@ -186,7 +186,7 @@
hasChildren?: boolean;
@property({type: String})
- patchNum!: PatchSetNum;
+ patchNum?: PatchSetNum;
@property({type: Boolean})
showActions?: boolean;
@@ -275,18 +275,6 @@
reporting = appContext.reportingService;
/** @override */
- ready() {
- super.ready();
- if (
- this.changeNum === undefined ||
- this.patchNum === undefined ||
- this.comment === undefined
- ) {
- throw new Error('Not all required properties are defined.');
- }
- }
-
- /** @override */
attached() {
super.attached();
this.$.restAPI.getAccount().then(account => {
@@ -460,7 +448,7 @@
resComment.__draft = true;
// Maintain the ephemeral draft ID for identification by other
// elements.
- if (this.comment.__draftID) {
+ if (this.comment?.__draftID) {
resComment.__draftID = this.comment.__draftID;
}
resComment.__commentSide = this.commentSide;
@@ -482,8 +470,10 @@
// prior to it being saved.
this.cancelDebouncer('store');
- if (!this.comment.path || this.comment.line === undefined)
- throw new Error('Cannot erase Draft Comment');
+ if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
+ if (this.changeNum === undefined) {
+ throw new Error('undefined changeNum');
+ }
this.$.storage.eraseDraftComment({
changeNum: this.changeNum,
patchNum: this._getPatchNum(),
@@ -504,12 +494,13 @@
@observe('comment', 'comments.*')
_computeHasHumanReply() {
- if (!this.comment || !this.comments) return;
+ const comment = this.comment;
+ if (!comment || !this.comments) return;
// hide please fix button for robot comment that has human reply
this._hasHumanReply = this.comments.some(
c =>
c.in_reply_to &&
- c.in_reply_to === this.comment.id &&
+ c.in_reply_to === comment.id &&
!(c as RobotComment).robot_id
);
}
@@ -582,7 +573,11 @@
return isAdmin && !draft ? 'showDeleteButtons' : '';
}
- _computeSaveDisabled(draft: string, comment: Comment, resolved?: boolean) {
+ _computeSaveDisabled(
+ draft: string,
+ comment: Comment | undefined,
+ resolved?: boolean
+ ) {
// If resolved state has changed and a msg exists, save should be enabled.
if (!comment || (comment.unresolved === resolved && draft)) {
return false;
@@ -628,37 +623,42 @@
return;
}
- this.debounce(
- 'store',
- () => {
- const message = this._messageText;
- if (!this.comment.path || this.comment.line === undefined)
- throw new Error('missing path or line in comment');
- const commentLocation: StorageLocation = {
- changeNum: this.changeNum,
- patchNum: this._getPatchNum(),
- path: this.comment.path,
- line: this.comment.line,
- range: this.comment.range,
- };
+ const patchNum = this.comment.patch_set
+ ? this.comment.patch_set
+ : this._getPatchNum();
+ const {path, line, range} = this.comment;
+ if (path) {
+ this.debounce(
+ 'store',
+ () => {
+ const message = this._messageText;
+ if (this.changeNum === undefined) {
+ throw new Error('undefined changeNum');
+ }
+ const commentLocation: StorageLocation = {
+ changeNum: this.changeNum,
+ patchNum,
+ path,
+ line,
+ range,
+ };
- if ((!this._messageText || !this._messageText.length) && oldValue) {
- // If the draft has been modified to be empty, then erase the storage
- // entry.
- this.$.storage.eraseDraftComment(commentLocation);
- } else {
- this.$.storage.setDraftComment(commentLocation, message);
- }
- },
- STORAGE_DEBOUNCE_INTERVAL
- );
+ if ((!message || !message.length) && oldValue) {
+ // If the draft has been modified to be empty, then erase the storage
+ // entry.
+ this.$.storage.eraseDraftComment(commentLocation);
+ } else {
+ this.$.storage.setDraftComment(commentLocation, message);
+ }
+ },
+ STORAGE_DEBOUNCE_INTERVAL
+ );
+ }
}
_handleAnchorClick(e: Event) {
e.preventDefault();
- if (!this.comment.line) {
- return;
- }
+ if (!this.comment) return;
this.dispatchEvent(
new CustomEvent('comment-anchor-tap', {
bubbles: true,
@@ -673,8 +673,7 @@
_handleEdit(e: Event) {
e.preventDefault();
- if (!this.comment.message) throw new Error('message undefined');
- this._messageText = this.comment.message;
+ if (this.comment?.message) this._messageText = this.comment.message;
this.editing = true;
this.reporting.recordDraftInteraction();
}
@@ -686,7 +685,7 @@
if (this.disabled) {
return;
}
- const timingLabel = this.comment.id
+ const timingLabel = this.comment?.id
? REPORT_UPDATE_DRAFT
: REPORT_CREATE_DRAFT;
const timer = this.reporting.getTimer(timingLabel);
@@ -700,7 +699,7 @@
e.preventDefault();
if (
- !this.comment.message ||
+ !this.comment?.message ||
this.comment.message.trim().length === 0 ||
!this.comment.id
) {
@@ -773,6 +772,7 @@
}
_discardDraft() {
+ if (!this.comment) return Promise.reject(new Error('undefined comment'));
if (!this.comment.__draft) {
return Promise.reject(new Error('Cannot discard a non-draft comment.'));
}
@@ -871,7 +871,10 @@
this._handleFailedDraftRequest();
}
- _saveDraft(draft: Comment) {
+ _saveDraft(draft?: Comment) {
+ if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
+ throw new Error('undefined draft or changeNum or patchNum');
+ }
this._showStartRequest();
return this.$.restAPI
.saveDiffDraft(this.changeNum, this.patchNum, draft)
@@ -893,6 +896,9 @@
}
_deleteDraft(draft: Comment) {
+ if (this.changeNum === undefined || this.patchNum === undefined) {
+ throw new Error('undefined changeNum or patchNum');
+ }
this._showStartRequest();
return this.$.restAPI
.deleteDiffDraft(this.changeNum, this.patchNum, draft)
@@ -907,7 +913,11 @@
}
_getPatchNum(): PatchSetNum {
- return this.isOnParent() ? ('PARENT' as PatchSetNum) : this.patchNum;
+ const patchNum = this.isOnParent()
+ ? ('PARENT' as PatchSetNum)
+ : this.patchNum;
+ if (patchNum === undefined) throw new Error('patchNum undefined');
+ return patchNum;
}
@observe('changeNum', 'patchNum', 'comment')
@@ -931,9 +941,7 @@
comment.id ||
comment.message ||
comment.__otherEditing ||
- !comment.path ||
- !comment.line ||
- !comment.range
+ !comment.path
) {
if (comment) delete comment.__otherEditing;
return;
@@ -958,6 +966,9 @@
// Modify payload instead of this.comment, as this.comment is passed from
// the parent by ref.
const payload = this._getEventPayload();
+ if (!payload.comment) {
+ throw new Error('comment not defined in payload');
+ }
payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
this.dispatchEvent(
new CustomEvent('comment-update', {
@@ -1007,6 +1018,13 @@
if (!dialog || !dialog.message) {
throw new Error('missing confirm delete dialog');
}
+ if (
+ !this.comment ||
+ this.changeNum === undefined ||
+ this.patchNum === undefined
+ ) {
+ throw new Error('undefined comment or changeNum or patchNum');
+ }
this.$.restAPI
.deleteComment(
this.changeNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 1299b90..3847135 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -19,6 +19,7 @@
import './gr-comment.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
const basicFixture = fixtureFromElement('gr-comment');
@@ -149,6 +150,7 @@
email: 'tenn1sballchaser@aol.com',
},
line: 5,
+ path: 'test',
};
flush(() => {
assert.isTrue(loadSpy.called);
@@ -315,6 +317,7 @@
});
test('create', () => {
+ element.patchNum = 1;
element.comment = {};
return element._handleSave(mockEvent).then(() => {
assert.equal(element.shadowRoot.querySelector('gr-account-label').
@@ -368,11 +371,13 @@
test('failed save draft request', done => {
element.draft = true;
+ element.changeNum = 1;
+ element.patchNum = 1;
const updateRequestStub = sinon.stub(element, '_updateRequestToast');
const diffDraftStub =
sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
Promise.resolve({ok: false}));
- element._saveDraft();
+ element._saveDraft({id: 'abc_123'});
flush(() => {
let args = updateRequestStub.lastCall.args;
assert.deepEqual(args, [0, true]);
@@ -384,7 +389,7 @@
.querySelector('.save')), 'save is visible');
diffDraftStub.returns(
Promise.resolve({ok: true}));
- element._saveDraft();
+ element._saveDraft({id: 'abc_123'});
flush(() => {
args = updateRequestStub.lastCall.args;
assert.deepEqual(args, [0]);
@@ -402,11 +407,13 @@
test('failed save draft request with promise failure', done => {
element.draft = true;
+ element.changeNum = 1;
+ element.patchNum = 1;
const updateRequestStub = sinon.stub(element, '_updateRequestToast');
const diffDraftStub =
sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
Promise.reject(new Error()));
- element._saveDraft();
+ element._saveDraft({id: 'abc_123'});
flush(() => {
let args = updateRequestStub.lastCall.args;
assert.deepEqual(args, [0, true]);
@@ -418,7 +425,7 @@
.querySelector('.save')), 'save is visible');
diffDraftStub.returns(
Promise.resolve({ok: true}));
- element._saveDraft();
+ element._saveDraft({id: 'abc_123'});
flush(() => {
args = updateRequestStub.lastCall.args;
assert.deepEqual(args, [0]);
@@ -721,6 +728,27 @@
});
});
+ test('patchset level comment', done => {
+ const comment = {...element.comment,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
+ range: undefined};
+ element.comment = comment;
+ flushAsynchronousOperations();
+ MockInteractions.tap(element.shadowRoot
+ .querySelector('.edit'));
+ assert.isTrue(element.editing);
+
+ element._messageText = 'hello world';
+ const eraseMessageDraftSpy = sinon.spy(element.$.storage,
+ 'eraseDraftComment');
+ const mockEvent = {preventDefault: sinon.stub()};
+ element._handleSave(mockEvent);
+ flush(() => {
+ assert.isTrue(eraseMessageDraftSpy.called);
+ done();
+ });
+ });
+
test('draft creation/cancellation', done => {
assert.isFalse(element.editing);
element.draft = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index a880320..47a1cbb 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -34,6 +34,18 @@
// Time in which pressing n key again after the toast navigates to next file
const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+ /** The cursor was successfully moved. */
+ MOVED,
+ /** There were no stops - the cursor was reset. */
+ NO_STOPS,
+ /** There was no more stop to move to - the cursor was clipped to the end. */
+ CLIPPED,
+}
+
@customElement('gr-cursor-manager')
export class GrCursorManager extends GestureEventListeners(
LegacyElementMixin(PolymerElement)
@@ -104,16 +116,16 @@
* back to first instead of to last.
* @param navigateToNextFile Navigate to next unreviewed file
* if user presses next on the last diff chunk
+ * @return If a move was performed or why not.
* @private
*/
-
next(
condition?: Function,
getTargetHeight?: (target: HTMLElement) => number,
clipToTop?: boolean,
navigateToNextFile?: boolean
- ) {
- this._moveCursor(
+ ): CursorMoveResult {
+ return this._moveCursor(
1,
condition,
getTargetHeight,
@@ -122,8 +134,8 @@
);
}
- previous(condition?: Function) {
- this._moveCursor(-1, condition);
+ previous(condition?: Function): CursorMoveResult {
+ return this._moveCursor(-1, condition);
}
/**
@@ -233,7 +245,9 @@
}
isAtEnd() {
- return this.index === this.stops.length - 1;
+ // Unset cursor should not be considered "at end", even when there are no
+ // cursor stops.
+ return this.index !== -1 && this.index === this.stops.length - 1;
}
moveToStart() {
@@ -267,6 +281,7 @@
* back to first instead of to last.
* @param navigateToNextFile Navigate to next unreviewed file
* if user presses next on the last diff chunk
+ * @return If a move was performed or why not.
* @private
*/
_moveCursor(
@@ -275,62 +290,58 @@
getTargetHeight?: (target: HTMLElement) => number,
clipToTop?: boolean,
navigateToNextFile?: boolean
- ) {
+ ): CursorMoveResult {
if (!this.stops.length) {
this.unsetCursor();
- return;
+ return CursorMoveResult.NO_STOPS;
}
this._unDecorateTarget();
const newIndex = this._getNextindex(delta, condition, clipToTop);
+ const newTarget = newIndex !== -1 ? this.stops[newIndex] : null;
- let newTarget = null;
- if (newIndex !== -1) {
- newTarget = this.stops[newIndex];
- }
+ const clipped = this.index === newIndex;
/*
* If user presses n on the last diff chunk, show a toast informing user
* that pressing n again will navigate them to next unreviewed file.
* If click happens within the time limit, then navigate to next file
*/
- if (navigateToNextFile && this.index === newIndex) {
- if (newIndex === this.stops.length - 1) {
- if (
- this._lastDisplayedNavigateToNextFileToast &&
- Date.now() - this._lastDisplayedNavigateToNextFileToast <=
- NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
- ) {
- // reset for next file
- this._lastDisplayedNavigateToNextFileToast = null;
- this.dispatchEvent(
- new CustomEvent('navigate-to-next-unreviewed-file', {
- composed: true,
- bubbles: true,
- })
- );
- return;
- }
- this._lastDisplayedNavigateToNextFileToast = Date.now();
+ if (navigateToNextFile && clipped && this.isAtEnd()) {
+ if (
+ this._lastDisplayedNavigateToNextFileToast &&
+ Date.now() - this._lastDisplayedNavigateToNextFileToast <=
+ NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
+ ) {
+ // reset for next file
+ this._lastDisplayedNavigateToNextFileToast = null;
this.dispatchEvent(
- new CustomEvent('show-alert', {
- detail: {
- message: 'Press n again to navigate to next unreviewed file',
- },
+ new CustomEvent('navigate-to-next-unreviewed-file', {
composed: true,
bubbles: true,
})
);
- return;
+ return CursorMoveResult.CLIPPED;
}
+ this._lastDisplayedNavigateToNextFileToast = Date.now();
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Press n again to navigate to next unreviewed file',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return CursorMoveResult.CLIPPED;
}
this.index = newIndex;
this.target = newTarget as HTMLElement;
if (!newTarget) {
- return;
+ return CursorMoveResult.NO_STOPS;
}
if (getTargetHeight) {
@@ -344,6 +355,8 @@
}
this._decorateTarget();
+
+ return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
}
_decorateTarget() {
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index bc07d84..5c0bb42 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -18,6 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-cursor-manager.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {CursorMoveResult} from './gr-cursor-manager.js';
const basicTestFixutre = fixtureFromTemplate(html`
<gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
@@ -66,10 +67,11 @@
assert.isFalse(element.isAtEnd());
// Progress the cursor.
- element.next();
+ let result = element.next();
// Confirm that the next stop is selected and that the previous stop is
// unselected.
+ assert.equal(result, CursorMoveResult.MOVED);
assert.equal(element.index, 3);
assert.equal(element.target, list.children[3]);
assert.isTrue(element.isAtEnd());
@@ -77,19 +79,23 @@
assert.isTrue(list.children[3].classList.contains('targeted'));
// Progress the cursor.
- element.next();
+ result = element.next();
// We should still be at the end.
+ assert.equal(result, CursorMoveResult.CLIPPED);
assert.equal(element.index, 3);
assert.equal(element.target, list.children[3]);
assert.isTrue(element.isAtEnd());
// Wind the cursor all the way back to the first stop.
- element.previous();
- element.previous();
- element.previous();
+ result = element.previous();
+ assert.equal(result, CursorMoveResult.MOVED);
+ result = element.previous();
+ assert.equal(result, CursorMoveResult.MOVED);
+ result = element.previous();
+ assert.equal(result, CursorMoveResult.MOVED);
- // The element state should reflect the end of the list.
+ // The element state should reflect the start of the list.
assert.equal(element.index, 0);
assert.equal(element.target, list.children[0]);
assert.isTrue(element.isAtStart());
@@ -113,8 +119,9 @@
test('next() goes to first element when no cursor is set', () => {
element.stops = list.querySelectorAll('li');
- element.next();
+ const result = element.next();
+ assert.equal(result, CursorMoveResult.MOVED);
assert.equal(element.index, 0);
assert.equal(element.target, list.children[0]);
assert.isTrue(list.children[0].classList.contains('targeted'));
@@ -122,10 +129,23 @@
assert.isFalse(element.isAtEnd());
});
- test('next() goes to first element when no cursor is set', () => {
- element.stops = list.querySelectorAll('li');
- element.previous();
+ test('next() resets the cursor when there are no stops', () => {
+ element.stops = [];
+ const result = element.next();
+ assert.equal(result, CursorMoveResult.NO_STOPS);
+ assert.equal(element.index, -1);
+ assert.isNotOk(element.target);
+ assert.isFalse(list.children[1].classList.contains('targeted'));
+ assert.isFalse(element.isAtStart());
+ assert.isFalse(element.isAtEnd());
+ });
+
+ test('previous() goes to last element when no cursor is set', () => {
+ element.stops = list.querySelectorAll('li');
+ const result = element.previous();
+
+ assert.equal(result, CursorMoveResult.MOVED);
const lastIndex = list.children.length - 1;
assert.equal(element.index, lastIndex);
assert.equal(element.target, list.children[lastIndex]);
@@ -134,6 +154,18 @@
assert.isTrue(element.isAtEnd());
});
+ test('previous() resets the cursor when there are no stops', () => {
+ element.stops = [];
+ const result = element.previous();
+
+ assert.equal(result, CursorMoveResult.NO_STOPS);
+ assert.equal(element.index, -1);
+ assert.isNotOk(element.target);
+ assert.isFalse(list.children[1].classList.contains('targeted'));
+ assert.isFalse(element.isAtStart());
+ assert.isFalse(element.isAtEnd());
+ });
+
test('_moveCursor', () => {
// Initialize the cursor with its stops.
element.stops = list.querySelectorAll('li');
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
index 468bbee..bc1dfe0 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -55,6 +55,7 @@
list-style-type: disc;
margin-left: var(--spacing-xl);
}
+ code,
gr-linked-text.pre {
font-family: var(--monospace-font-family);
font-size: var(--font-size-code);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index c4ebe1e..a3233be 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -137,6 +137,11 @@
this.listen(this, 'mouseleave', 'unlock');
}
+ detached() {
+ super.detached();
+ this.unlock();
+ }
+
/** @override */
ready() {
super.ready();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 3dfe105..8461027 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -125,6 +125,7 @@
ProjectAccessInfo,
CapabilityInfoMap,
ProjectInfoWithName,
+ TagInfo,
} from '../../../types/common';
import {
CancelConditionCallback,
@@ -1795,11 +1796,11 @@
`/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
- return this._restApiHelper.fetchJSON({
+ return (this._restApiHelper.fetchJSON({
url,
errFn,
anonymizedUrl: '/projects/*/tags',
- });
+ }) as unknown) as Promise<TagInfo[]>;
}
getPlugins(
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
index 15914c5..3233420 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -24,7 +24,7 @@
changeNum: number;
patchNum: PatchSetNum;
path: string;
- line: number;
+ line?: number;
range?: CommentRange;
}
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index a1703a2..4e56daf 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -829,13 +829,18 @@
shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
const e = getKeyboardEvent(event);
// TODO(TS): maybe override the EventApi, narrow it down to Element always
- const tagName = ((dom(e) as EventApi).rootTarget as Element).tagName;
+ const target = (dom(e) as EventApi).rootTarget as Element;
+ const tagName = target.tagName;
+ const type = target.getAttribute('type');
if (
- tagName === 'INPUT' ||
+ // Suppress shortcuts on <input> and <textarea>, but not on
+ // checkboxes, because we want to enable workflows like 'click
+ // mark-reviewed and then press ] to go to the next file'.
+ (tagName === 'INPUT' && type !== 'checkbox') ||
tagName === 'TEXTAREA' ||
+ // Suppress shortcuts if the key is 'enter' and target is an anchor.
(e.keyCode === 13 && tagName === 'A')
) {
- // Suppress shortcuts if the key is 'enter' and target is an anchor.
return true;
}
for (let i = 0; e.path && i < e.path.length; i++) {
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
index defc7dc..180dbe7 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
@@ -311,6 +311,17 @@
MockInteractions.keyDownOn(inputEl, 75, null, 'k');
});
+ test('doesn’t block kb shortcuts for checkboxes', done => {
+ const inputEl = document.createElement('input');
+ inputEl.setAttribute('type', 'checkbox');
+ element.appendChild(inputEl);
+ element._handleKey = e => {
+ assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+ done();
+ };
+ MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+ });
+
test('blocks kb shortcuts for textarea els', done => {
const textareaEl = document.createElement('textarea');
element.appendChild(textareaEl);
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 05500ec..c85e307 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -79,6 +79,9 @@
EncodedGroupId,
Base64FileContent,
UrlEncodedCommentId,
+ TagInfo,
+ GitRef,
+ ConfigInput,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {HttpMethod} from '../../../constants/constants';
@@ -675,4 +678,16 @@
path: string,
contents: string
): Promise<Response>;
+ getRepoTags(
+ filter: string,
+ repo: RepoName,
+ reposTagsPerPage: number,
+ offset?: number,
+ errFn?: ErrorCallback
+ ): Promise<TagInfo[]>;
+
+ setRepoHead(repo: RepoName, ref: GitRef): Promise<Response>;
+ deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
+ deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
+ saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.js b/polygerrit-ui/app/test/common-test-setup-karma.js
index cc934fc..2335f28 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.js
+++ b/polygerrit-ui/app/test/common-test-setup-karma.js
@@ -20,10 +20,15 @@
self.assert = window.chai.assert;
self.expect = window.chai.expect;
+// Workaround for https://github.com/karma-runner/karma-mocha/issues/227
+let unhandledError = null;
+
window.addEventListener('error', e => {
// For uncaught error mochajs doesn't print the full stack trace.
// We should print it ourselves.
+ console.error('Uncaught error:');
console.error(e.error.stack.toString());
+ unhandledError = e;
});
let originalOnBeforeUnload;
@@ -50,6 +55,9 @@
suiteTeardown(() => {
// This suiteTeardown() method is called only once after all tests
window.onbeforeunload = originalOnBeforeUnload;
+ if (unhandledError) {
+ throw unhandledError;
+ }
});
// Tests can use fake timers (sandbox.useFakeTimers)
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index eec0fc3..e7539b5 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -793,16 +793,19 @@
new_value: string;
}
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
/**
* The DownloadInfo entity contains information about supported download
* options.
* https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
*/
export interface DownloadInfo {
- schemes: string;
+ schemes: SchemesInfoMap;
archives: string;
}
+export type CloneCommandMap = {[name: string]: string};
/**
* The DownloadSchemeInfo entity contains information about a supported download
* scheme and its commands.
@@ -813,7 +816,7 @@
is_auth_required: boolean;
is_auth_supported: boolean;
commands: string;
- clone_commands: string;
+ clone_commands: CloneCommandMap;
}
/**
@@ -1347,14 +1350,6 @@
reject_empty_commit?: InheritedBooleanInfo;
}
-export type PluginParameterToConfigParameterInfoMap = {
- [parameterName: string]: ConfigParameterInfo;
-};
-
-export type PluginNameToPluginParametersMap = {
- [pluginName: string]: PluginParameterToConfigParameterInfoMap;
-};
-
/**
* The ProjectAccessInfo entity contains information about the access rights for a project
* https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
@@ -1447,23 +1442,31 @@
use_signed_off_by?: InheritedBooleanInfoConfiguredValue;
create_new_change_for_all_not_in_target?: InheritedBooleanInfoConfiguredValue;
require_change_id?: InheritedBooleanInfoConfiguredValue;
+ enable_signed_push?: InheritedBooleanInfoConfiguredValue;
+ require_signed_push?: InheritedBooleanInfoConfiguredValue;
+ private_by_default?: InheritedBooleanInfoConfiguredValue;
+ work_in_progress_by_default?: InheritedBooleanInfoConfiguredValue;
+ enable_reviewer_by_email?: InheritedBooleanInfoConfiguredValue;
+ match_author_to_committer_date?: InheritedBooleanInfoConfiguredValue;
reject_implicit_merges?: InheritedBooleanInfoConfiguredValue;
+ reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
max_object_size_limit?: MaxObjectSizeLimitInfo;
submit_type?: SubmitType;
state?: ProjectState;
- plugin_config_values?: PluginConfigValues;
- reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
+ plugin_config_values?: PluginNameToPluginParametersMap;
commentlinks?: ConfigInfoCommentLinks;
}
-
/**
* Plugin configuration values as map which maps the plugin name to a map of parameter names to values
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
*/
-export type PluginConfigValues = {
- [pluginName: string]: ParameterNameToValueMap;
+export type PluginNameToPluginParametersMap = {
+ [pluginName: string]: PluginParameterToConfigParameterInfoMap;
};
-export type ParameterNameToValueMap = {[parameterName: string]: string};
+
+export type PluginParameterToConfigParameterInfoMap = {
+ [parameterName: string]: ConfigParameterInfo;
+};
export type ConfigInfoCommentLinks = {
[commentLinkName: string]: CommentLinkInfo;
@@ -1490,7 +1493,6 @@
enable_signed_push?: InheritedBooleanInfoConfiguredValue;
require_signed_push?: InheritedBooleanInfoConfiguredValue;
max_object_size_limit?: string;
- plugin_config_values?: PluginConfigValues;
reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
}
@@ -2008,3 +2010,18 @@
title: string;
url: string;
}
+
+/**
+ * The TagInfo entity contains information about a tag.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-info
+ **/
+export interface TagInfo {
+ ref: GitRef;
+ revision: string;
+ object?: string;
+ message?: string;
+ tagger?: GitPersonInfo;
+ created?: string;
+ can_delete: boolean;
+ web_links?: WebLinkInfo[];
+}