Merge "Submit Requirements - show new UI only when SR returned from back-end"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 240b0ed..870e194 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1857,7 +1857,8 @@
* The given change.
* If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
- is enabled, include all open changes with the same topic.
+ is enabled OR if the `o=TOPIC_CLOSURE` query parameter is passed, include all
+ open changes with the same topic.
* For each change whose submit type is not CHERRY_PICK, include unmerged
ancestors targeting the same branch.
@@ -1884,7 +1885,7 @@
Standard link:#query-options[formatting options] can be specified
with the `o` parameter, as well as the `submitted_together` specific
-option `NON_VISIBLE_CHANGES`.
+options `NON_VISIBLE_CHANGES` and `TOPIC_CLOSURE`.
.Response
----
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 9ca881d..2fb2127 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,6 +26,7 @@
import static com.google.gerrit.entities.Patch.COMMIT_MSG;
import static com.google.gerrit.entities.Patch.MERGE_LIST;
import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -41,6 +42,7 @@
import com.google.common.base.Ticker;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.jimfs.Jimfs;
@@ -80,6 +82,7 @@
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
import com.google.gerrit.extensions.api.projects.BranchApi;
import com.google.gerrit.extensions.api.projects.BranchInfo;
import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -1203,9 +1206,37 @@
}
protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
- List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+ assertSubmittedTogether(chId, ImmutableSet.of(), expected);
+ }
+
+ protected void assertSubmittedTogetherWithTopicClosure(String chId, String... expected)
+ throws Exception {
+ assertSubmittedTogether(chId, ImmutableSet.of(TOPIC_CLOSURE), expected);
+ }
+
+ protected void assertSubmittedTogether(
+ String chId,
+ ImmutableSet<SubmittedTogetherOption> submittedTogetherOptions,
+ String... expected)
+ throws Exception {
+ // This does not include NON_VISIBILE_CHANGES
+ List<ChangeInfo> actual =
+ submittedTogetherOptions.isEmpty()
+ ? gApi.changes().id(chId).submittedTogether()
+ : gApi.changes()
+ .id(chId)
+ .submittedTogether(EnumSet.copyOf(submittedTogetherOptions))
+ .changes;
+
+ EnumSet enumSetIncludingNonVisibleChanges =
+ submittedTogetherOptions.isEmpty()
+ ? EnumSet.of(NON_VISIBLE_CHANGES)
+ : EnumSet.copyOf(submittedTogetherOptions);
+ enumSetIncludingNonVisibleChanges.add(NON_VISIBLE_CHANGES);
+
+ // This includes NON_VISIBLE_CHANGES for comparison.
SubmittedTogetherInfo info =
- gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+ gApi.changes().id(chId).submittedTogether(enumSetIncludingNonVisibleChanges);
assertThat(info.nonVisibleChanges).isEqualTo(0);
assertThat(Iterables.transform(actual, i1 -> i1.changeId))
diff --git a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index e2cab4d..68a4e88 100644
--- a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,5 +16,6 @@
/** Output options available for submitted_together requests. */
public enum SubmittedTogetherOption {
- NON_VISIBLE_CHANGES;
+ NON_VISIBLE_CHANGES,
+ TOPIC_CLOSURE;
}
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 3f642f7..51c5ecd 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -64,7 +64,7 @@
/** Returns true if the account is backed by the realm, false otherwise. */
default boolean accountBelongsToRealm(
- @SuppressWarnings("unused") Collection<ExternalId> externalIds) {
+ @SuppressWarnings("unused") Collection<ExternalId> externalIds) throws IOException {
return false;
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 5155a0d..154e45a 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -304,7 +304,10 @@
return null; // submit not visible
}
- ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+ ChangeSet cs =
+ mergeSuperSet
+ .get()
+ .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
String topic = change.getTopic();
int topicSize = 0;
if (!Strings.isNullOrEmpty(topic)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 214a001..c18e7c2 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
import static java.util.Collections.reverseOrder;
import static java.util.stream.Collectors.toList;
@@ -127,7 +128,10 @@
int hidden;
if (c.isNew()) {
- ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
+ ChangeSet cs =
+ mergeSuperSet
+ .get()
+ .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
cds = ensureRequiredDataIsLoaded(cs.changes().asList());
hidden = cs.nonVisibleChanges().size();
} else if (c.isMerged()) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 64b60bb..b431299 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -480,7 +480,9 @@
logger.atFine().log("Beginning integration of %s", change);
try {
ChangeSet indexBackedChangeSet =
- mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
+ mergeSuperSet
+ .setMergeOpRepoManager(orm)
+ .completeChangeSet(change, caller, /* includingTopicClosure= */ false);
if (!indexBackedChangeSet.ids().contains(change.getId())) {
// indexBackedChangeSet contains only open changes, if the change is missing in this set
// it might be that the change was concurrently submitted in the meantime.
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 67f2907..8581e20 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -92,7 +92,19 @@
return this;
}
- public ChangeSet completeChangeSet(Change change, CurrentUser user)
+ /**
+ * Gets the ChangeSet of this {@code change} based on visiblity of the {@code user}. if
+ * change.submitWholeTopic is true, we return the topic closure as well as the dependent changes
+ * of the topic closure. Otherwise, we return just the dependent changes.
+ *
+ * @param change the change for which we get the dependent changes / topic closure.
+ * @param user the current user for visibility purposes.
+ * @param includingTopicClosure when true, return as if change.submitWholeTopic = true, so we
+ * return the topic closure.
+ * @return {@link ChangeSet} object that represents the dependent changes and/or topic closure of
+ * the requested change.
+ */
+ public ChangeSet completeChangeSet(Change change, CurrentUser user, boolean includingTopicClosure)
throws IOException, PermissionBackendException {
try {
if (orm == null) {
@@ -113,7 +125,7 @@
}
ChangeSet changeSet = new ChangeSet(cd, visible);
- if (wholeTopicEnabled(cfg)) {
+ if (wholeTopicEnabled(cfg) || includingTopicClosure) {
return completeChangeSetIncludingTopics(changeSet, user);
}
try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index a63d60a..0a9a098 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -306,7 +306,10 @@
private void assertChangeSetMergeable(ChangeData change, boolean expected)
throws MissingObjectException, IncorrectObjectTypeException, IOException,
PermissionBackendException {
- ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
+ ChangeSet cs =
+ mergeSuperSet
+ .get()
+ .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index a97fb49..7e0bce9 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -130,6 +130,8 @@
} else {
assertSubmittedTogether(id1);
assertSubmittedTogether(id2);
+ assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
}
}
@@ -152,6 +154,8 @@
} else {
assertSubmittedTogether(id1);
assertSubmittedTogether(id2);
+ assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
}
}
@@ -180,6 +184,9 @@
assertSubmittedTogether(id1);
assertSubmittedTogether(id2);
assertSubmittedTogether(id3, id3, id2);
+ assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id3, id3, id2, id1);
}
}
@@ -227,6 +234,13 @@
assertSubmittedTogether(id4, id4, id3, id2);
assertSubmittedTogether(id5);
assertSubmittedTogether(id6, id6, id5);
+
+ assertSubmittedTogetherWithTopicClosure(id1, id6, id5, id3, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id2, id6, id5, id2);
+ assertSubmittedTogetherWithTopicClosure(id3, id6, id5, id3, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id4, id6, id5, id4, id3, id2, id1);
+ assertSubmittedTogetherWithTopicClosure(id5);
+ assertSubmittedTogetherWithTopicClosure(id6, id6, id5, id2);
}
}
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0029f5c..def693d 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -100,4 +100,6 @@
ATTENTION_SET_CHIP = 'attention-set-chip',
SAVE_COMMENT = 'save-comment',
COMMENT_SAVED = 'comment-saved',
+ DISCARD_COMMENT = 'discard-comment',
+ COMMENT_DISCARDED = 'comment-discarded',
}
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 172f807..f811ee2 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -71,7 +71,7 @@
];
interface Rule {
- value: RuleValue;
+ value?: RuleValue;
}
interface RuleValue {
@@ -158,17 +158,17 @@
// Observer _handleValueChange is called after the ready()
// method finishes. Original values must be set later to
// avoid set .modified flag to true
- this._setOriginalRuleValues(this.rule.value);
+ this._setOriginalRuleValues(this.rule?.value);
}
}
- _setupValues(rule: Rule) {
- if (!rule.value) {
+ _setupValues(rule?: Rule) {
+ if (!rule?.value) {
this._setDefaultRuleValues();
}
}
- _computeForce(permission: AccessPermissionId, action: string) {
+ _computeForce(permission: AccessPermissionId, action?: string) {
if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
return true;
}
@@ -176,7 +176,7 @@
return AccessPermissionId.EDIT_TOPIC_NAME === permission;
}
- _computeForceClass(permission: AccessPermissionId, action: string) {
+ _computeForceClass(permission: AccessPermissionId, action?: string) {
return this._computeForce(permission, action) ? 'force' : '';
}
@@ -213,7 +213,7 @@
return classList.join(' ');
}
- _computeForceOptions(permission: string, action: string) {
+ _computeForceOptions(permission: string, action?: string) {
if (permission === AccessPermissionId.PUSH) {
if (action === Action.ALLOW) {
return ForcePushOptions.ALLOW;
@@ -259,7 +259,7 @@
}
_handleRemoveRule() {
- if (!this.rule) return;
+ if (!this.rule?.value) return;
if (this.rule.value.added) {
fireEvent(this, 'added-rule-removed');
}
@@ -269,13 +269,13 @@
}
_handleUndoRemove() {
- if (!this.rule) return;
+ if (!this.rule?.value) return;
this._deleted = false;
delete this.rule.value.deleted;
}
_handleUndoChange() {
- if (!this.rule) return;
+ if (!this.rule?.value) return;
// gr-permission will take care of removing rules that were added but
// unsaved. We need to keep the added bit for the filter.
if (this.rule.value.added) {
@@ -289,7 +289,7 @@
@observe('rule.value.*')
_handleValueChange() {
- if (!this._originalRuleValues || !this.rule) {
+ if (!this._originalRuleValues || !this.rule?.value) {
return;
}
this.rule.value.modified = true;
@@ -297,7 +297,8 @@
fireEvent(this, 'access-modified');
}
- _setOriginalRuleValues(value: RuleValue) {
+ _setOriginalRuleValues(value?: RuleValue) {
+ if (value === undefined) return;
this._originalRuleValues = {...value};
}
}
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
deleted file mode 100644
index f3df132..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ /dev/null
@@ -1,586 +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 '../../../test/common-test-setup-karma.js';
-import './gr-rule-editor.js';
-
-const basicFixture = fixtureFromElement('gr-rule-editor');
-
-suite('gr-rule-editor tests', () => {
- let element;
-
- setup(() => {
- element = basicFixture.instantiate();
- });
-
- suite('unit tests', () => {
- test('_computeForce, _computeForceClass, and _computeForceOptions',
- () => {
- const ForcePushOptions = {
- ALLOW: [
- {name: 'Allow pushing (but not force pushing)', value: false},
- {name: 'Allow pushing with or without force', value: true},
- ],
- BLOCK: [
- {name: 'Block pushing with or without force', value: false},
- {name: 'Block force pushing', value: true},
- ],
- };
-
- const FORCE_EDIT_OPTIONS = [
- {
- name: 'No Force Edit',
- value: false,
- },
- {
- name: 'Force Edit',
- value: true,
- },
- ];
- let permission = 'push';
- let action = 'ALLOW';
- assert.isTrue(element._computeForce(permission, action));
- assert.equal(element._computeForceClass(permission, action),
- 'force');
- assert.deepEqual(element._computeForceOptions(permission, action),
- ForcePushOptions.ALLOW);
-
- action = 'BLOCK';
- assert.isTrue(element._computeForce(permission, action));
- assert.equal(element._computeForceClass(permission, action),
- 'force');
- assert.deepEqual(element._computeForceOptions(permission, action),
- ForcePushOptions.BLOCK);
-
- action = 'DENY';
- assert.isFalse(element._computeForce(permission, action));
- assert.equal(element._computeForceClass(permission, action), '');
- assert.equal(
- element._computeForceOptions(permission, action).length, 0);
-
- permission = 'editTopicName';
- assert.isTrue(element._computeForce(permission));
- assert.equal(element._computeForceClass(permission), 'force');
- assert.deepEqual(element._computeForceOptions(permission),
- FORCE_EDIT_OPTIONS);
- permission = 'submit';
- assert.isFalse(element._computeForce(permission));
- assert.equal(element._computeForceClass(permission), '');
- assert.deepEqual(element._computeForceOptions(permission), []);
- });
-
- test('_computeSectionClass', () => {
- let deleted = true;
- let editing = false;
- assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
- deleted = false;
- assert.equal(element._computeSectionClass(editing, deleted), '');
-
- editing = true;
- assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
- deleted = true;
- assert.equal(element._computeSectionClass(editing, deleted),
- 'editing deleted');
- });
-
- test('_getDefaultRuleValues', () => {
- let permission = 'priority';
- let label;
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'BATCH'});
- permission = 'label-Code-Review';
- label = {values: [
- {value: -2, text: 'This shall not be merged'},
- {value: -1, text: 'I would prefer this is not merged as is'},
- {value: -0, text: 'No score'},
- {value: 1, text: 'Looks good to me, but someone else must approve'},
- {value: 2, text: 'Looks good to me, approved'},
- ]};
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'ALLOW', max: 2, min: -2});
- permission = 'push';
- label = undefined;
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'ALLOW', force: false});
- permission = 'submit';
- assert.deepEqual(element._getDefaultRuleValues(permission, label),
- {action: 'ALLOW'});
- });
-
- test('_setDefaultRuleValues', () => {
- element.rule = {id: 123};
- const defaultValue = {action: 'ALLOW'};
- sinon.stub(element, '_getDefaultRuleValues').returns(defaultValue);
- element._setDefaultRuleValues();
- assert.isTrue(element._getDefaultRuleValues.called);
- assert.equal(element.rule.value, defaultValue);
- });
-
- test('_computeOptions', () => {
- const PRIORITY_OPTIONS = [
- 'BATCH',
- 'INTERACTIVE',
- ];
- const DROPDOWN_OPTIONS = [
- 'ALLOW',
- 'DENY',
- 'BLOCK',
- ];
- let permission = 'priority';
- assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
- permission = 'submit';
- assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
- });
-
- test('_handleValueChange', () => {
- const modifiedHandler = sinon.stub();
- element.rule = {value: {}};
- element.addEventListener('access-modified', modifiedHandler);
- element._handleValueChange();
- assert.isNotOk(element.rule.value.modified);
- element._originalRuleValues = {};
- element._handleValueChange();
- assert.isTrue(element.rule.value.modified);
- assert.isTrue(modifiedHandler.called);
- });
-
- test('_handleAccessSaved', () => {
- const originalValue = {action: 'DENY'};
- const newValue = {action: 'ALLOW'};
- element._originalRuleValues = originalValue;
- element.rule = {value: newValue};
- element._handleAccessSaved();
- assert.deepEqual(element._originalRuleValues, newValue);
- });
-
- test('_setOriginalRuleValues', () => {
- const value = {
- action: 'ALLOW',
- force: false,
- };
- element._setOriginalRuleValues(value);
- assert.deepEqual(element._originalRuleValues, value);
- });
- });
-
- suite('already existing generic rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'submit';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: false,
- },
- };
- element.section = 'refs/*';
-
- // Typically called on ready since elements will have properties defined
- // by the parent element.
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.isNotOk(element.root.querySelector('#labelMin'));
- assert.isNotOk(element.root.querySelector('#labelMax'));
- assert.isFalse(element.$.force.classList.contains('force'));
- });
-
- test('modify and cancel restores original values', () => {
- element.editing = true;
- assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = 'DENY';
- assert.isTrue(element.rule.value.modified);
- element.editing = false;
- assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- assert.equal(element.$.action.bindValue, 'ALLOW');
- assert.isNotOk(element.rule.value.modified);
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = 'DENY';
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('all selects are disabled when not in edit mode', () => {
- const selects = element.root.querySelectorAll('select');
- for (const select of selects) {
- assert.isTrue(select.disabled);
- }
- element.editing = true;
- for (const select of selects) {
- assert.isFalse(select.disabled);
- }
- });
-
- test('remove rule and undo remove', () => {
- element.editing = true;
- element.rule = {id: 123, value: {action: 'ALLOW'}};
- assert.isFalse(
- element.$.deletedContainer.classList.contains('deleted'));
- MockInteractions.tap(element.$.removeBtn);
- assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
- assert.isTrue(element._deleted);
- assert.isTrue(element.rule.value.deleted);
-
- MockInteractions.tap(element.$.undoRemoveBtn);
- assert.isFalse(element._deleted);
- assert.isNotOk(element.rule.value.deleted);
- });
-
- test('remove rule and cancel', () => {
- element.editing = true;
- assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.equal(getComputedStyle(element.$.deletedContainer).display,
- 'none');
-
- element.rule = {id: 123, value: {action: 'ALLOW'}};
- MockInteractions.tap(element.$.removeBtn);
- assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
- 'none');
- assert.isTrue(element._deleted);
- assert.isTrue(element.rule.value.deleted);
-
- element.editing = false;
- assert.isFalse(element._deleted);
- assert.isNotOk(element.rule.value.deleted);
- assert.isNotOk(element.rule.value.modified);
-
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
- assert.equal(getComputedStyle(element.$.deletedContainer).display,
- 'none');
- });
-
- test('_computeGroupPath', () => {
- const group = '123';
- assert.equal(element._computeGroupPath(group),
- `/admin/groups/123`);
- });
- });
-
- suite('new edit rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'editTopicName';
- element.rule = {
- id: '123',
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.rule.value.added = true;
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- // Since the element does not already have default values, they should
- // be set. The original values should be set to those too.
- assert.isNotOk(element.rule.value.modified);
- const expectedRuleValue = {
- action: 'ALLOW',
- force: false,
- added: true,
- };
- assert.deepEqual(element.rule.value, expectedRuleValue);
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, expectedRuleValue.action);
- assert.equal(element.$.force.bindValue, expectedRuleValue.action);
- });
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.force.bindValue = true;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('remove value', () => {
- element.editing = true;
- const removeStub = sinon.stub();
- element.addEventListener('added-rule-removed', removeStub);
- MockInteractions.tap(element.$.removeBtn);
- flush();
- assert.isTrue(removeStub.called);
- });
- });
-
- suite('already existing rule with labels', () => {
- setup(async () => {
- element.label = {values: [
- {value: -2, text: 'This shall not be merged'},
- {value: -1, text: 'I would prefer this is not merged as is'},
- {value: -0, text: 'No score'},
- {value: 1, text: 'Looks good to me, but someone else must approve'},
- {value: 2, text: 'Looks good to me, approved'},
- ]};
- element.group = 'Group Name';
- element.permission = 'label-Code-Review';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: false,
- max: 2,
- min: -2,
- },
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.equal(
- element.root.querySelector('#labelMin').bindValue,
- element.rule.value.min);
- assert.equal(
- element.root.querySelector('#labelMax').bindValue,
- element.rule.value.max);
- assert.isFalse(element.$.force.classList.contains('force'));
- });
-
- test('modify value', () => {
- const removeStub = sinon.stub();
- element.addEventListener('added-rule-removed', removeStub);
- assert.isNotOk(element.rule.value.modified);
- element.root.querySelector('#labelMin').bindValue = 1;
- flush();
- assert.isTrue(element.rule.value.modified);
- assert.isFalse(removeStub.called);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('new rule with labels', () => {
- setup(async () => {
- sinon.spy(element, '_setDefaultRuleValues');
- element.label = {values: [
- {value: -2, text: 'This shall not be merged'},
- {value: -1, text: 'I would prefer this is not merged as is'},
- {value: -0, text: 'No score'},
- {value: 1, text: 'Looks good to me, but someone else must approve'},
- {value: 2, text: 'Looks good to me, approved'},
- ]};
- element.group = 'Group Name';
- element.permission = 'label-Code-Review';
- element.rule = {
- id: '123',
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.rule.value.added = true;
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- // Since the element does not already have default values, they should
- // be set. The original values should be set to those too.
- assert.isNotOk(element.rule.value.modified);
- assert.isTrue(element._setDefaultRuleValues.called);
-
- const expectedRuleValue = {
- max: element.label.values[element.label.values.length - 1].value,
- min: element.label.values[0].value,
- action: 'ALLOW',
- added: true,
- };
- assert.deepEqual(element.rule.value, expectedRuleValue);
- test('values are set correctly', () => {
- assert.equal(
- element.$.action.bindValue,
- expectedRuleValue.action);
- assert.equal(
- element.root.querySelector('#labelMin').bindValue,
- expectedRuleValue.min);
- assert.equal(
- element.root.querySelector('#labelMax').bindValue,
- expectedRuleValue.max);
- });
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.root.querySelector('#labelMin').bindValue = 1;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('already existing push rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'push';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: true,
- },
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.isTrue(element.$.force.classList.contains('force'));
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.equal(
- element.root.querySelector('#force').bindValue,
- element.rule.value.force);
- assert.isNotOk(element.root.querySelector('#labelMin'));
- assert.isNotOk(element.root.querySelector('#labelMax'));
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = false;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('new push rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'push';
- element.rule = {
- id: '123',
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.rule.value.added = true;
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- // Since the element does not already have default values, they should
- // be set. The original values should be set to those too.
- assert.isNotOk(element.rule.value.modified);
- const expectedRuleValue = {
- action: 'ALLOW',
- force: false,
- added: true,
- };
- assert.deepEqual(element.rule.value, expectedRuleValue);
- test('values are set correctly', () => {
- assert.equal(element.$.action.bindValue, expectedRuleValue.action);
- assert.equal(element.$.force.bindValue, expectedRuleValue.action);
- });
- });
-
- test('modify value', () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.force.bindValue = true;
- flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-
- suite('already existing edit rule', () => {
- setup(async () => {
- element.group = 'Group Name';
- element.permission = 'editTopicName';
- element.rule = {
- id: '123',
- value: {
- action: 'ALLOW',
- force: true,
- },
- };
- element.section = 'refs/*';
- element._setupValues(element.rule);
- await flush();
- element.connectedCallback();
- });
-
- test('_ruleValues and _originalRuleValues are set correctly', () => {
- assert.deepEqual(element._originalRuleValues, element.rule.value);
- });
-
- test('values are set correctly', () => {
- assert.isTrue(element.$.force.classList.contains('force'));
- assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.equal(
- element.root.querySelector('#force').bindValue,
- element.rule.value.force);
- assert.isNotOk(element.root.querySelector('#labelMin'));
- assert.isNotOk(element.root.querySelector('#labelMax'));
- });
-
- test('modify value', async () => {
- assert.isNotOk(element.rule.value.modified);
- element.$.action.bindValue = false;
- await flush();
- assert.isTrue(element.rule.value.modified);
-
- // The original value should now differ from the rule values.
- assert.notDeepEqual(element._originalRuleValues, element.rule.value);
- });
- });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
new file mode 100644
index 0000000..1afd123
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -0,0 +1,685 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import './gr-rule-editor';
+import {GrRuleEditor} from './gr-rule-editor';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-rule-editor');
+
+suite('gr-rule-editor tests', () => {
+ let element: GrRuleEditor;
+
+ setup(() => {
+ element = basicFixture.instantiate();
+ });
+
+ suite('unit tests', () => {
+ test('_computeForce, _computeForceClass, and _computeForceOptions', () => {
+ const ForcePushOptions = {
+ ALLOW: [
+ {name: 'Allow pushing (but not force pushing)', value: false},
+ {name: 'Allow pushing with or without force', value: true},
+ ],
+ BLOCK: [
+ {name: 'Block pushing with or without force', value: false},
+ {name: 'Block force pushing', value: true},
+ ],
+ };
+
+ const FORCE_EDIT_OPTIONS = [
+ {
+ name: 'No Force Edit',
+ value: false,
+ },
+ {
+ name: 'Force Edit',
+ value: true,
+ },
+ ];
+ let permission = 'push' as AccessPermissionId;
+ let action = 'ALLOW';
+ assert.isTrue(element._computeForce(permission, action));
+ assert.equal(element._computeForceClass(permission, action), 'force');
+ assert.deepEqual(
+ element._computeForceOptions(permission, action),
+ ForcePushOptions.ALLOW
+ );
+
+ action = 'BLOCK';
+ assert.isTrue(element._computeForce(permission, action));
+ assert.equal(element._computeForceClass(permission, action), 'force');
+ assert.deepEqual(
+ element._computeForceOptions(permission, action),
+ ForcePushOptions.BLOCK
+ );
+
+ action = 'DENY';
+ assert.isFalse(element._computeForce(permission, action));
+ assert.equal(element._computeForceClass(permission, action), '');
+ assert.equal(element._computeForceOptions(permission, action).length, 0);
+
+ permission = 'editTopicName' as AccessPermissionId;
+ assert.isTrue(element._computeForce(permission));
+ assert.equal(element._computeForceClass(permission), 'force');
+ assert.deepEqual(
+ element._computeForceOptions(permission),
+ FORCE_EDIT_OPTIONS
+ );
+ permission = 'submit' as AccessPermissionId;
+ assert.isFalse(element._computeForce(permission));
+ assert.equal(element._computeForceClass(permission), '');
+ assert.deepEqual(element._computeForceOptions(permission), []);
+ });
+
+ test('_computeSectionClass', () => {
+ let deleted = true;
+ let editing = false;
+ assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+ deleted = false;
+ assert.equal(element._computeSectionClass(editing, deleted), '');
+
+ editing = true;
+ assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+ deleted = true;
+ assert.equal(
+ element._computeSectionClass(editing, deleted),
+ 'editing deleted'
+ );
+ });
+
+ test('_getDefaultRuleValues', () => {
+ let permission = 'priority' as AccessPermissionId;
+ let label;
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'BATCH',
+ });
+ permission = 'label-Code-Review' as AccessPermissionId;
+ label = {
+ values: [
+ {value: -2, text: 'This shall not be merged'},
+ {value: -1, text: 'I would prefer this is not merged as is'},
+ {value: -0, text: 'No score'},
+ {value: 1, text: 'Looks good to me, but someone else must approve'},
+ {value: 2, text: 'Looks good to me, approved'},
+ ],
+ };
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'ALLOW',
+ max: 2,
+ min: -2,
+ });
+ permission = 'push' as AccessPermissionId;
+ label = undefined;
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'ALLOW',
+ force: false,
+ });
+ permission = 'submit' as AccessPermissionId;
+ assert.deepEqual(element._getDefaultRuleValues(permission, label), {
+ action: 'ALLOW',
+ });
+ });
+
+ test('_setDefaultRuleValues', async () => {
+ element.rule = {value: {}};
+ const defaultValue = {action: 'ALLOW'};
+ const getDefaultRuleValuesStub = sinon
+ .stub(element, '_getDefaultRuleValues')
+ .returns(defaultValue);
+ element._setDefaultRuleValues();
+ assert.isTrue(getDefaultRuleValuesStub.called);
+ assert.equal(element.rule!.value, defaultValue);
+ });
+
+ test('_computeOptions', () => {
+ const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+ const DROPDOWN_OPTIONS = ['ALLOW', 'DENY', 'BLOCK'];
+ let permission = 'priority';
+ assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+ permission = 'submit';
+ assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+ });
+
+ test('_handleValueChange', () => {
+ const modifiedHandler = sinon.stub();
+ element.rule = {value: {}};
+ element.addEventListener('access-modified', modifiedHandler);
+ element._handleValueChange();
+ assert.isNotOk(element.rule!.value!.modified);
+ element._originalRuleValues = {};
+ element._handleValueChange();
+ assert.isTrue(element.rule!.value!.modified);
+ assert.isTrue(modifiedHandler.called);
+ });
+
+ test('_handleAccessSaved', () => {
+ const originalValue = {action: 'DENY'};
+ const newValue = {action: 'ALLOW'};
+ element._originalRuleValues = originalValue;
+ element.rule = {value: newValue};
+ element._handleAccessSaved();
+ assert.deepEqual(element._originalRuleValues, newValue);
+ });
+
+ test('_setOriginalRuleValues', () => {
+ const value = {
+ action: 'ALLOW',
+ force: false,
+ };
+ element._setOriginalRuleValues(value);
+ assert.deepEqual(element._originalRuleValues, value);
+ });
+ });
+
+ suite('already existing generic rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'submit' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: false,
+ },
+ };
+ element.section = 'refs/*';
+
+ // Typically called on ready since elements will have properties defined
+ // by the parent element.
+ element._setupValues(element.rule);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+ assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+ assert.isFalse(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ });
+
+ test('modify and cancel restores original values', () => {
+ element.editing = true;
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = 'DENY';
+ assert.isTrue(element.rule!.value!.modified);
+ element.editing = false;
+ assert.equal(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ 'ALLOW'
+ );
+ assert.isNotOk(element.rule!.value!.modified);
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = 'DENY';
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('all selects are disabled when not in edit mode', () => {
+ const selects = queryAll<HTMLSelectElement>(element, 'select');
+ for (const select of selects) {
+ assert.isTrue(select.disabled);
+ }
+ element.editing = true;
+ for (const select of selects) {
+ assert.isFalse(select.disabled);
+ }
+ });
+
+ test('remove rule and undo remove', () => {
+ element.editing = true;
+ element.rule = {value: {action: 'ALLOW'}};
+ assert.isFalse(
+ queryAndAssert<HTMLDivElement>(
+ element,
+ '#deletedContainer'
+ ).classList.contains('deleted')
+ );
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+ assert.isTrue(
+ queryAndAssert<HTMLDivElement>(
+ element,
+ '#deletedContainer'
+ ).classList.contains('deleted')
+ );
+ assert.isTrue(element._deleted);
+ assert.isTrue(element.rule!.value!.deleted);
+
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+ assert.isFalse(element._deleted);
+ assert.isNotOk(element.rule!.value!.deleted);
+ });
+
+ test('remove rule and cancel', () => {
+ element.editing = true;
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.equal(
+ getComputedStyle(
+ queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+ ).display,
+ 'none'
+ );
+
+ element.rule = {value: {action: 'ALLOW'}};
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+ assert.notEqual(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.notEqual(
+ getComputedStyle(
+ queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+ ).display,
+ 'none'
+ );
+ assert.isTrue(element._deleted);
+ assert.isTrue(element.rule!.value!.deleted);
+
+ element.editing = false;
+ assert.isFalse(element._deleted);
+ assert.isNotOk(element.rule!.value!.deleted);
+ assert.isNotOk(element.rule!.value!.modified);
+
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ assert.equal(
+ getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+ .display,
+ 'none'
+ );
+ assert.equal(
+ getComputedStyle(
+ queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+ ).display,
+ 'none'
+ );
+ });
+
+ test('_computeGroupPath', () => {
+ const group = '123';
+ assert.equal(element._computeGroupPath(group), '/admin/groups/123');
+ });
+ });
+
+ suite('new edit rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'editTopicName' as AccessPermissionId;
+ element.rule = {};
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.rule!.value!.added = true;
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ // Since the element does not already have default values, they should
+ // be set. The original values should be set to those too.
+ assert.isNotOk(element.rule!.value!.modified);
+ const expectedRuleValue = {
+ action: 'ALLOW',
+ force: false,
+ added: true,
+ };
+ assert.deepEqual(element.rule!.value, expectedRuleValue);
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ expectedRuleValue.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ expectedRuleValue.action
+ );
+ });
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+ forceBindValue.bindValue = true;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('remove value', () => {
+ element.editing = true;
+ const removeStub = sinon.stub();
+ element.addEventListener('added-rule-removed', removeStub);
+ MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+ flush();
+ assert.isTrue(removeStub.called);
+ });
+ });
+
+ suite('already existing rule with labels', () => {
+ setup(async () => {
+ element.label = {
+ values: [
+ {value: -2, text: 'This shall not be merged'},
+ {value: -1, text: 'I would prefer this is not merged as is'},
+ {value: -0, text: 'No score'},
+ {value: 1, text: 'Looks good to me, but someone else must approve'},
+ {value: 2, text: 'Looks good to me, approved'},
+ ],
+ };
+ element.groupName = 'Group Name';
+ element.permission = 'label-Code-Review' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: false,
+ max: 2,
+ min: -2,
+ },
+ };
+ element.section = 'refs/*';
+ element._setupValues(element.rule);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+ element.rule!.value!.min
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+ element.rule!.value!.max
+ );
+ assert.isFalse(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ });
+
+ test('modify value', () => {
+ const removeStub = sinon.stub();
+ element.addEventListener('added-rule-removed', removeStub);
+ assert.isNotOk(element.rule!.value!.modified);
+ const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+ labelMinBindValue.bindValue = 1;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+ assert.isFalse(removeStub.called);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('new rule with labels', () => {
+ let setDefaultRuleValuesSpy: sinon.SinonSpy;
+
+ setup(async () => {
+ setDefaultRuleValuesSpy = sinon.spy(element, '_setDefaultRuleValues');
+ element.label = {
+ values: [
+ {value: -2, text: 'This shall not be merged'},
+ {value: -1, text: 'I would prefer this is not merged as is'},
+ {value: -0, text: 'No score'},
+ {value: 1, text: 'Looks good to me, but someone else must approve'},
+ {value: 2, text: 'Looks good to me, approved'},
+ ],
+ };
+ element.groupName = 'Group Name';
+ element.permission = 'label-Code-Review' as AccessPermissionId;
+ element.rule = {};
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.rule!.value!.added = true;
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ // Since the element does not already have default values, they should
+ // be set. The original values should be set to those too.
+ assert.isNotOk(element.rule!.value!.modified);
+ assert.isTrue(setDefaultRuleValuesSpy.called);
+
+ const expectedRuleValue = {
+ max: element.label!.values![element.label!.values.length - 1].value,
+ min: element.label!.values![0].value,
+ action: 'ALLOW',
+ added: true,
+ };
+ assert.deepEqual(element.rule!.value, expectedRuleValue);
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ expectedRuleValue.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+ expectedRuleValue.min
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+ expectedRuleValue.max
+ );
+ });
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+ labelMinBindValue.bindValue = 1;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('already existing push rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'push' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: true,
+ },
+ };
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.isTrue(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ element.rule!.value!.force
+ );
+ assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+ assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = false;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('new push rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'push' as AccessPermissionId;
+ element.rule = {};
+ element.section = 'refs/*';
+ element._setupValues(element.rule!);
+ await flush();
+ element.rule!.value!.added = true;
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ // Since the element does not already have default values, they should
+ // be set. The original values should be set to those too.
+ assert.isNotOk(element.rule!.value!.modified);
+ const expectedRuleValue = {
+ action: 'ALLOW',
+ force: false,
+ added: true,
+ };
+ assert.deepEqual(element.rule!.value, expectedRuleValue);
+ test('values are set correctly', () => {
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ expectedRuleValue.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ expectedRuleValue.action
+ );
+ });
+ });
+
+ test('modify value', () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+ forceBindValue.bindValue = true;
+ flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+
+ suite('already existing edit rule', () => {
+ setup(async () => {
+ element.groupName = 'Group Name';
+ element.permission = 'editTopicName' as AccessPermissionId;
+ element.rule = {
+ value: {
+ action: 'ALLOW',
+ force: true,
+ },
+ };
+ element.section = 'refs/*';
+ element._setupValues(element.rule);
+ await flush();
+ element.connectedCallback();
+ });
+
+ test('_ruleValues and _originalRuleValues are set correctly', () => {
+ assert.deepEqual(element._originalRuleValues, element.rule!.value);
+ });
+
+ test('values are set correctly', () => {
+ assert.isTrue(
+ queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#action').bindValue,
+ element.rule!.value!.action
+ );
+ assert.equal(
+ queryAndAssert<GrSelect>(element, '#force').bindValue,
+ element.rule!.value!.force
+ );
+ assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+ assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+ });
+
+ test('modify value', async () => {
+ assert.isNotOk(element.rule!.value!.modified);
+ const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+ actionBindValue.bindValue = false;
+ await flush();
+ assert.isTrue(element.rule!.value!.modified);
+
+ // The original value should now differ from the rule values.
+ assert.notDeepEqual(element._originalRuleValues, element.rule!.value);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index e635613..83808af 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -386,7 +386,7 @@
// Accessed in tests
readonly jsAPI = getAppContext().jsApiService;
- private readonly changeService = getAppContext().changeService;
+ private readonly changeModel = getAppContext().changeModel;
@property({type: Object})
change?: ChangeViewChangeInfo;
@@ -1716,7 +1716,7 @@
new Error('Properties change and changeNum must be set.')
);
}
- return this.changeService.fetchChangeUpdates(change).then(result => {
+ return this.changeModel.fetchChangeUpdates(change).then(result => {
if (!result.isLatest) {
this.dispatchEvent(
new CustomEvent<ShowAlertEventDetail>('show-alert', {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index f072fa3..338c015 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -20,10 +20,6 @@
import './gr-change-metadata';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
- _testOnly_initGerritPluginApi,
- GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
import {GrChangeMetadata} from './gr-change-metadata';
import {
createServerInfo,
@@ -71,11 +67,9 @@
const basicFixture = fixtureFromElement('gr-change-metadata');
suite('gr-change-metadata tests', () => {
- let pluginApi: GerritInternal;
let element: GrChangeMetadata;
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
stubRestApi('getConfig').returns(
Promise.resolve({
@@ -897,7 +891,7 @@
}
let hookEl: MetadataGrEndpointDecorator;
let plugin: PluginApi;
- pluginApi.install(
+ window.Gerrit.install(
p => {
plugin = p;
plugin
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index f59fdec..02d1beb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -20,15 +20,9 @@
import {sharedStyles} from '../../../styles/shared-styles';
import {getAppContext} from '../../../services/app-context';
import {
- allRunsLatestPatchsetLatestAttempt$,
- aPluginHasRegistered$,
CheckResult,
CheckRun,
ErrorMessages,
- errorMessagesLatest$,
- loginCallbackLatest$,
- someProvidersAreLoadingFirstTime$,
- topLevelActionsLatest$,
} from '../../../services/checks/checks-model';
import {Action, Category, Link, RunStatus} from '../../../api/checks';
import {fireShowPrimaryTab} from '../../../utils/event-util';
@@ -65,10 +59,6 @@
import {modifierPressed} from '../../../utils/dom-util';
import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
import {fontStyles} from '../../../styles/gr-font-styles';
-import {
- changeComments$,
- threads$,
-} from '../../../services/comments/comments-model';
export enum SummaryChipStyles {
INFO = 'info',
@@ -411,24 +401,54 @@
private showAllChips = new Map<RunStatus | Category, boolean>();
+ private commentsModel = getAppContext().commentsModel;
+
private userModel = getAppContext().userModel;
- private checksService = getAppContext().checksService;
+ private checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
- subscribe(this, aPluginHasRegistered$, x => (this.showChecksSummary = x));
subscribe(
this,
- someProvidersAreLoadingFirstTime$,
+ this.checksModel.allRunsLatestPatchsetLatestAttempt$,
+ x => (this.runs = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.aPluginHasRegistered$,
+ x => (this.showChecksSummary = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.someProvidersAreLoadingFirstTime$,
x => (this.someProvidersAreLoading = x)
);
- subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
- subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
- subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
- subscribe(this, changeComments$, x => (this.changeComments = x));
- subscribe(this, threads$, x => (this.commentThreads = x));
+ subscribe(
+ this,
+ this.checksModel.errorMessagesLatest$,
+ x => (this.errorMessages = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.loginCallbackLatest$,
+ x => (this.loginCallback = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.topLevelActionsLatest$,
+ x => (this.actions = x)
+ );
+ subscribe(
+ this,
+ this.commentsModel.changeComments$,
+ x => (this.changeComments = x)
+ );
+ subscribe(
+ this,
+ this.commentsModel.threads$,
+ x => (this.commentThreads = x)
+ );
subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
}
@@ -560,7 +580,7 @@
}
private handleAction(e: CustomEvent<Action>) {
- this.checksService.triggerAction(e.detail);
+ this.checksModel.triggerAction(e.detail);
}
private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 31e7be7..e397117 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -141,7 +141,7 @@
isDraftThread,
isRobot,
isUnresolved,
- UIDraft,
+ DraftInfo,
} from '../../../utils/comment-util';
import {
PolymerDeepPropertyChange,
@@ -181,7 +181,6 @@
fireTitleChange,
} from '../../../utils/event-util';
import {GerritView, routerView$} from '../../../services/router/router-model';
-import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
import {
debounce,
DelayedTask,
@@ -192,20 +191,12 @@
import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
import {getRevertCreatedChangeIds} from '../../../utils/message-util';
import {
- changeComments$,
- drafts$,
-} from '../../../services/comments/comments-model';
-import {
getAddedByReason,
getRemovedByReason,
hasAttention,
} from '../../../utils/attention-set-util';
import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {
- change$,
- changeLoadingStatus$,
- LoadingStatus,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
@@ -282,13 +273,6 @@
* @event show-auth-required
*/
- // Accessed in tests.
- readonly reporting = getAppContext().reportingService;
-
- readonly jsAPI = getAppContext().jsApiService;
-
- private readonly changeService = getAppContext().changeService;
-
/**
* URL params passed from the router.
*/
@@ -367,7 +351,7 @@
_changeNum?: NumericChangeId;
@property({type: Object})
- _diffDrafts?: {[path: string]: UIDraft[]} = {};
+ _diffDrafts?: {[path: string]: DraftInfo[]} = {};
@property({type: Boolean})
_editingCommitMessage = false;
@@ -559,15 +543,6 @@
})
resolveWeblinks?: GeneratedWebLink[];
- readonly restApiService = getAppContext().restApiService;
-
- // Private but used in tests.
- readonly userModel = getAppContext().userModel;
-
- private readonly commentsService = getAppContext().commentsService;
-
- private readonly shortcuts = getAppContext().shortcutsService;
-
override keyboardShortcuts(): ShortcutListener[] {
return [
listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -614,6 +589,25 @@
];
}
+ // Accessed in tests.
+ readonly reporting = getAppContext().reportingService;
+
+ readonly jsAPI = getAppContext().jsApiService;
+
+ private readonly checksModel = getAppContext().checksModel;
+
+ readonly restApiService = getAppContext().restApiService;
+
+ // Private but used in tests.
+ readonly userModel = getAppContext().userModel;
+
+ // Private but used in tests.
+ readonly changeModel = getAppContext().changeModel;
+
+ private readonly commentsModel = getAppContext().commentsModel;
+
+ private readonly shortcuts = getAppContext().shortcutsService;
+
private subscriptions: Subscription[] = [];
private replyRefitTask?: DelayedTask;
@@ -633,7 +627,7 @@
override ready() {
super.ready();
this.subscriptions.push(
- aPluginHasRegistered$.subscribe(b => {
+ this.checksModel.aPluginHasRegistered$.subscribe(b => {
this._showChecksTab = b;
})
);
@@ -643,7 +637,7 @@
})
);
this.subscriptions.push(
- drafts$.subscribe(drafts => {
+ this.commentsModel.drafts$.subscribe(drafts => {
this._diffDrafts = {...drafts};
})
);
@@ -653,12 +647,12 @@
})
);
this.subscriptions.push(
- changeComments$.subscribe(changeComments => {
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this._changeComments = changeComments;
})
);
this.subscriptions.push(
- change$.subscribe(change => {
+ this.changeModel.change$.subscribe(change => {
// The change view is tied to a specific change number, so don't update
// _change to undefined.
if (change) this._change = change;
@@ -1530,7 +1524,7 @@
}
_computeReplyButtonLabel(
- drafts?: {[path: string]: UIDraft[]},
+ drafts?: {[path: string]: DraftInfo[]},
canStartReview?: boolean
) {
if (drafts === undefined || canStartReview === undefined) {
@@ -1880,7 +1874,7 @@
}
const detailCompletes = until(
- changeLoadingStatus$,
+ this.changeModel.changeLoadingStatus$,
status => status === LoadingStatus.LOADED
);
const editCompletes = this._getEdit();
@@ -2213,13 +2207,13 @@
const promises = [this._getCommitInfo(), this.$.fileList.reload()];
if (patchNumChanged) {
promises.push(
- this.commentsService.reloadPortedComments(
+ this.commentsModel.reloadPortedComments(
this._changeNum,
this._patchRange?.patchNum
)
);
promises.push(
- this.commentsService.reloadPortedDrafts(
+ this.commentsModel.reloadPortedDrafts(
this._changeNum,
this._patchRange?.patchNum
)
@@ -2329,7 +2323,7 @@
return;
}
const change = this._change;
- this.changeService.fetchChangeUpdates(change).then(result => {
+ this.changeModel.fetchChangeUpdates(change).then(result => {
let toastMessage = null;
if (!result.isLatest) {
toastMessage = ReloadToastMessage.NEWER_REVISION;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index eea6d3a..13fb5b3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -565,10 +565,6 @@
<h3 class="assistive-tech-only">Comments</h3>
<gr-thread-list
threads="[[_commentThreads]]"
- change="[[_change]]"
- change-num="[[_changeNum]]"
- logged-in="[[_loggedIn]]"
- account="[[_account]]"
comment-tab-state="[[_tabState.commentTab]]"
only-show-robot-comments-with-human-reply=""
unresolved-only="[[unresolvedOnly]]"
@@ -597,14 +593,7 @@
value="[[_currentRobotCommentsPatchSet]]"
>
</gr-dropdown-list>
- <gr-thread-list
- threads="[[_robotCommentThreads]]"
- change="[[_change]]"
- change-num="[[_changeNum]]"
- logged-in="[[_loggedIn]]"
- hide-dropdown
- empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
- >
+ <gr-thread-list threads="[[_robotCommentThreads]]" hide-dropdown>
</gr-thread-list>
<template is="dom-if" if="[[_showRobotCommentsButton]]">
<gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 88be4b8..0e74cc6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -32,10 +32,6 @@
import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
- _testOnly_initGerritPluginApi,
- GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
import {EventType, PluginApi} from '../../../api/plugin';
import 'lodash/lodash';
@@ -90,6 +86,7 @@
RevisionInfo,
RevisionPatchSetNum,
RobotId,
+ RobotCommentInfo,
Timestamp,
UrlEncodedCommentId,
} from '../../../types/common';
@@ -101,15 +98,12 @@
import {AppElementChangeViewParams} from '../../gr-app-types';
import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread, UIRobot} from '../../../utils/comment-util';
+import {CommentThread} from '../../../utils/comment-util';
import {GerritView} from '../../../services/router/router-model';
import {ParsedChangeInfo} from '../../../types/types';
import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {
- LoadingStatus,
- _testOnly_setState as setChangeState,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
@@ -118,7 +112,6 @@
suite('gr-change-view tests', () => {
let element: GrChangeView;
- let pluginApi: GerritInternal;
let navigateToChangeStub: SinonStubbedMember<
typeof GerritNav.navigateToChange
@@ -168,8 +161,6 @@
message: 'draft',
unresolved: false,
__draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
patch_set: 2 as PatchSetNum,
},
],
@@ -272,8 +263,6 @@
message: 'resolved draft',
unresolved: false,
__draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
patch_set: 2 as PatchSetNum,
},
],
@@ -348,7 +337,6 @@
];
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
// Since pluginEndpoints are global, must reset state.
_testOnly_resetEndpoints();
navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
@@ -372,7 +360,7 @@
element._changeNum = TEST_NUMERIC_CHANGE_ID;
sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
getPluginLoader().loadPlugins([]);
- pluginApi.install(
+ window.Gerrit.install(
plugin => {
plugin.registerDynamicCustomComponent(
'change-view-tab-header',
@@ -920,11 +908,13 @@
test('only robot comments are rendered', () => {
assert.equal(element._robotCommentThreads!.length, 2);
assert.equal(
- (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+ (element._robotCommentThreads![0].comments[0] as RobotCommentInfo)
+ .robot_id,
'rc1'
);
assert.equal(
- (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+ (element._robotCommentThreads![1].comments[0] as RobotCommentInfo)
+ .robot_id,
'rc2'
);
});
@@ -1493,7 +1483,7 @@
test('topic is coalesced to null', async () => {
sinon.stub(element, '_changeChanged');
- setChangeState({
+ element.changeModel.setState({
loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
@@ -1509,7 +1499,7 @@
test('commit sha is populated from getChangeDetail', async () => {
sinon.stub(element, '_changeChanged');
- setChangeState({
+ element.changeModel.setState({
loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
@@ -1526,7 +1516,7 @@
test('edit is added to change', () => {
sinon.stub(element, '_changeChanged');
const changeRevision = createRevision();
- setChangeState({
+ element.changeModel.setState({
loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
@@ -1947,7 +1937,7 @@
test('_selectedRevision updates when patchNum is changed', () => {
const revision1: RevisionInfo = createRevision(1);
const revision2: RevisionInfo = createRevision(2);
- setChangeState({
+ element.changeModel.setState({
loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
@@ -1978,7 +1968,7 @@
const revision1 = createRevision(1);
const revision2 = createRevision(2);
const revision3 = createEditRevision();
- setChangeState({
+ element.changeModel.setState({
loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
@@ -2144,7 +2134,11 @@
element._change = {...createChangeViewChange(), labels: {}};
element._selectedRevision = createRevision();
const promise = mockPromise();
- pluginApi.install(promise.resolve, '0.1', 'http://some/plugins/url.js');
+ window.Gerrit.install(
+ promise.resolve,
+ '0.1',
+ 'http://some/plugins/url.js'
+ );
await flush();
const plugin: PluginApi = (await promise) as PluginApi;
const hookEl = await plugin
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index a9b7b81..c24e054 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -29,8 +29,7 @@
import {customElement, property, query, state} from 'lit/decorators';
import {fontStyles} from '../../../styles/gr-font-styles';
import {subscribe} from '../../lit/subscription-controller';
-import {change$} from '../../../services/change/change-model';
-import {threads$} from '../../../services/comments/comments-model';
+import {getAppContext} from '../../../services/app-context';
import {ParsedChangeInfo} from '../../../types/types';
@customElement('gr-confirm-submit-dialog')
@@ -62,6 +61,10 @@
@state()
initialised = false;
+ private commentsModel = getAppContext().commentsModel;
+
+ private changeModel = getAppContext().changeModel;
+
static override get styles() {
return [
sharedStyles,
@@ -90,10 +93,10 @@
constructor() {
super();
- subscribe(this, change$, x => (this.change = x));
+ subscribe(this, this.changeModel.change$, x => (this.change = x));
subscribe(
this,
- threads$,
+ this.commentsModel.threads$,
x => (this.unresolvedThreads = x.filter(isUnresolved))
);
}
@@ -125,9 +128,6 @@
<gr-thread-list
id="commentList"
.threads="${this.unresolvedThreads}"
- .change="${this.change}"
- .changeNum="${this.change?._number}"
- logged-in
hide-dropdown
>
</gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index f286098..112077a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -83,7 +83,6 @@
import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
import {Timing} from '../../../constants/reporting';
import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {changeComments$} from '../../../services/comments/comments-model';
import {listen} from '../../../services/shortcuts/shortcuts-service';
import {select} from '../../../utils/observable-util';
@@ -316,6 +315,8 @@
private readonly userModel = getAppContext().userModel;
+ private readonly commentsModel = getAppContext().commentsModel;
+
private readonly browserModel = getAppContext().browserModel;
private subscriptions: Subscription[] = [];
@@ -375,7 +376,7 @@
override connectedCallback() {
super.connectedCallback();
this.subscriptions = [
- changeComments$.subscribe(changeComments => {
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this.changeComments = changeComments;
}),
this.browserModel.diffViewMode$.subscribe(
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 817c21b..fd6b0d4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -1469,18 +1469,11 @@
ignore_whitespace: 'IGNORE_NONE',
};
diff.diff = getMockDiffResponse();
- sinon.stub(diff.changeComments, 'getCommentsForPath')
- .withArgs('/COMMIT_MSG', {
- basePatchNum: 'PARENT',
- patchNum: 2,
- })
- .returns(diff.comments);
await listenOnce(diff, 'render');
}
async function renderAndGetNewDiffs(index) {
- const diffs =
- element.root.querySelectorAll('gr-diff-host');
+ const diffs = element.root.querySelectorAll('gr-diff-host');
for (let i = index; i < diffs.length; i++) {
await setupDiff(diffs[i]);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index a512b60..5d74d07 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -555,8 +555,10 @@
@observe('projectName')
_projectNameChanged(name?: string) {
- // Check if name is undefined to prevent errors.
- if (!name) return;
+ if (!name) {
+ this._projectConfig = undefined;
+ return;
+ }
this.restApiService.getProjectConfig(name as RepoName).then(config => {
this._projectConfig = config;
});
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 7f3e9de..8def279 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -280,13 +280,11 @@
</div>
</template>
<gr-thread-list
- change="[[change]]"
hidden$="[[!commentThreads.length]]"
threads="[[commentThreads]]"
- change-num="[[changeNum]]"
- logged-in="[[_loggedIn]]"
hide-dropdown
show-comment-context
+ message-id="[[message.id]]"
>
</gr-thread-list>
</template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index cedddec..36d0ce6 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -51,12 +51,6 @@
FormattedReviewerUpdateInfo,
ParsedChangeInfo,
} from '../../../types/types';
-import {threads$} from '../../../services/comments/comments-model';
-import {
- change$,
- changeNum$,
- repo$,
-} from '../../../services/change/change-model';
/**
* The content of the enum is also used in the UI for the button text.
@@ -100,17 +94,10 @@
message: CombinedMessage,
allThreadsForChange: CommentThread[]
): CommentThread[] {
- if (message._index === undefined) {
- return [];
- }
+ if (message._index === undefined) return [];
const messageId = getMessageId(message);
return allThreadsForChange.filter(thread =>
- thread.comments.some(comment => {
- const matchesMessage = comment.change_message_id === messageId;
- if (!matchesMessage) return false;
- comment.collapsed = !matchesMessage;
- return matchesMessage;
- })
+ thread.comments.some(comment => comment.change_message_id === messageId)
);
}
@@ -264,6 +251,11 @@
private readonly userModel = getAppContext().userModel;
+ // Private but used in tests.
+ readonly commentsModel = getAppContext().commentsModel;
+
+ private readonly changeModel = getAppContext().changeModel;
+
private readonly reporting = getAppContext().reportingService;
private readonly shortcuts = getAppContext().shortcutsService;
@@ -273,12 +265,12 @@
override connectedCallback() {
super.connectedCallback();
this.subscriptions.push(
- threads$.subscribe(x => {
+ this.commentsModel.threads$.subscribe(x => {
this.commentThreads = x;
})
);
this.subscriptions.push(
- change$.subscribe(x => {
+ this.changeModel.change$.subscribe(x => {
this.change = x;
})
);
@@ -288,12 +280,12 @@
})
);
this.subscriptions.push(
- repo$.subscribe(x => {
+ this.changeModel.repo$.subscribe(x => {
this.projectName = x;
})
);
this.subscriptions.push(
- changeNum$.subscribe(x => {
+ this.changeModel.changeNum$.subscribe(x => {
this.changeNum = x;
})
);
@@ -392,13 +384,6 @@
}
}
- // collapse all by default
- for (const thread of commentThreads) {
- for (const comment of thread.comments) {
- comment.collapsed = true;
- }
- }
-
for (let i = 0; i < combinedMessages.length; i++) {
const message = combinedMessages[i];
if (message.expanded === undefined) {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index 50e1a38..65c3c74 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -22,7 +22,6 @@
import {MessageTag} from '../../../constants/constants.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {stubRestApi} from '../../../test/test-utils.js';
-import {updateStateComments} from '../../../services/comments/comments-model.js';
createCommentApiMockWithTemplateElement(
'gr-messages-list-comment-mock-api', html`
@@ -129,7 +128,7 @@
};
suite('basic tests', () => {
- setup(() => {
+ setup(async () => {
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
stubRestApi('getDiffComments').returns(Promise.resolve(comments));
stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -140,9 +139,9 @@
// comment API.
commentApiWrapper = basicFixture.instantiate();
element = commentApiWrapper.$.messagesList;
- updateStateComments(comments);
+ await element.commentsModel.reloadComments();
element.messages = messages;
- flush();
+ await flush();
});
test('expand/collapse all', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 68d48b7..94b8668 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -47,10 +47,6 @@
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../types/types';
import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {
- _testOnly_initGerritPluginApi,
- GerritInternal,
-} from '../../shared/gr-js-api-interface/gr-gerrit';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import './gr-related-changes-list';
import {
@@ -64,10 +60,8 @@
suite('gr-related-changes-list', () => {
let element: GrRelatedChangesList;
- let pluginApi: GerritInternal;
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
element = basicFixture.instantiate();
});
@@ -609,7 +603,7 @@
}
let hookEl: RelatedChangesListGrEndpointDecorator;
let plugin: PluginApi;
- pluginApi.install(
+ window.Gerrit.install(
p => {
plugin = p;
plugin
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 8980642..8e78d4e 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -19,14 +19,12 @@
import './gr-reply-dialog.js';
import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
const basicFixture = fixtureFromElement('gr-reply-dialog');
suite('gr-reply-dialog-it tests', () => {
let element;
- let pluginApi;
let changeNum;
let patchNum;
@@ -71,7 +69,6 @@
};
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
changeNum = 42;
patchNum = 1;
@@ -102,7 +99,7 @@
test('lgtm plugin', async () => {
resetPlugins();
- pluginApi.install(plugin => {
+ window.Gerrit.install(plugin => {
const replyApi = plugin.changeReply();
replyApi.addReplyTextChangedCallback(text => {
const label = 'Code-Review';
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index dca0d65..52a5dac 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -218,7 +218,7 @@
private readonly reporting = getAppContext().reportingService;
- private readonly changeService = getAppContext().changeService;
+ private readonly changeModel = getAppContext().changeModel;
@property({type: Object})
change?: ChangeInfo;
@@ -435,7 +435,7 @@
open(focusTarget?: FocusTarget, quote?: string) {
assertIsDefined(this.change, 'change');
this.knownLatestState = LatestPatchState.CHECKING;
- this.changeService.fetchChangeUpdates(this.change).then(result => {
+ this.changeModel.fetchChangeUpdates(this.change).then(result => {
this.knownLatestState = result.isLatest
? LatestPatchState.LATEST
: LatestPatchState.NOT_LATEST;
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 4a8b996..719347c 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
@@ -394,9 +394,6 @@
id="commentList"
hidden$="[[!_includeComments]]"
threads="[[draftCommentThreads]]"
- change="[[change]]"
- change-num="[[change._number]]"
- logged-in="true"
hide-dropdown=""
>
</gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index df28175..14ad4ad 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -35,6 +35,7 @@
import {
createAccountWithId,
createChange,
+ createComment,
createCommentThread,
createDraft,
createRevision,
@@ -318,18 +319,13 @@
if (hasDraft) {
draftThreads = [
{
- ...createCommentThread([
- {
- ...createDraft(),
- __draft: true,
- unresolved: true,
- },
- ]),
+ ...createCommentThread([{...createDraft(), unresolved: true}]),
},
];
}
replyToIds?.forEach(id =>
draftThreads[0].comments.push({
+ ...createComment(),
author: {_account_id: id},
})
);
@@ -878,11 +874,13 @@
{
...createCommentThread([
{
+ ...createComment(),
id: '1' as UrlEncodedCommentId,
author: {_account_id: 1 as AccountId},
unresolved: false,
},
{
+ ...createComment(),
id: '2' as UrlEncodedCommentId,
in_reply_to: '1' as UrlEncodedCommentId,
author: {_account_id: 2 as AccountId},
@@ -893,11 +891,13 @@
{
...createCommentThread([
{
+ ...createComment(),
id: '3' as UrlEncodedCommentId,
author: {_account_id: 3 as AccountId},
unresolved: false,
},
{
+ ...createComment(),
id: '4' as UrlEncodedCommentId,
in_reply_to: '3' as UrlEncodedCommentId,
author: {_account_id: 4 as AccountId},
@@ -2003,7 +2003,7 @@
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ [
- {...createCommentThread([{__draft: true}])},
+ {...createCommentThread([createComment()])},
],
/* text= */ '',
/* reviewersMutated= */ false,
@@ -2023,7 +2023,7 @@
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
/* draftCommentThreads= */ [
- {...createCommentThread([{__draft: true}])},
+ {...createCommentThread([createComment()])},
],
/* text= */ '',
/* reviewersMutated= */ false,
@@ -2042,7 +2042,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ 'test',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
@@ -2060,7 +2062,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ true,
/* labelsChanged= */ false,
@@ -2078,7 +2082,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ true,
@@ -2096,7 +2102,9 @@
assert.isTrue(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ true,
@@ -2120,7 +2128,9 @@
assert.isFalse(
element._computeSendButtonDisabled(
/* canBeStarted= */ false,
- /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+ /* draftCommentThreads= */ [
+ {...createCommentThread([createComment()])},
+ ],
/* text= */ '',
/* reviewersMutated= */ false,
/* labelsChanged= */ false,
@@ -2144,7 +2154,12 @@
element.draftCommentThreads = [
{
...createCommentThread([
- {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+ {
+ ...createDraft(),
+ path: 'test',
+ line: 1,
+ patch_set: 1 as PatchSetNum,
+ },
]),
},
];
@@ -2167,7 +2182,12 @@
element.draftCommentThreads = [
{
...createCommentThread([
- {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+ {
+ ...createDraft(),
+ path: 'test',
+ line: 1,
+ patch_set: 1 as PatchSetNum,
+ },
]),
},
];
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 6521ad3..3fc2e0f 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -44,10 +44,7 @@
import {fontStyles} from '../../../styles/gr-font-styles';
import {charsOnly} from '../../../utils/string-util';
import {subscribe} from '../../lit/subscription-controller';
-import {
- allRunsLatestPatchsetLatestAttempt$,
- CheckRun,
-} from '../../../services/checks/checks-model';
+import {CheckRun} from '../../../services/checks/checks-model';
import {
firstPrimaryLink,
getResultsOf,
@@ -57,6 +54,7 @@
import '../../shared/gr-vote-chip/gr-vote-chip';
import {fireShowPrimaryTab} from '../../../utils/event-util';
import {PrimaryTab} from '../../../constants/constants';
+import {getAppContext} from '../../../services/app-context';
/**
* @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -149,9 +147,15 @@
];
}
+ private readonly checksModel = getAppContext().checksModel;
+
constructor() {
super();
- subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+ subscribe(
+ this,
+ this.checksModel.allRunsLatestPatchsetLatestAttempt$,
+ x => (this.runs = x)
+ );
}
override render() {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 87c62b1..0349b3f 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -17,50 +17,36 @@
import '../../../styles/shared-styles';
import '../../shared/gr-comment-thread/gr-comment-thread';
import '../../shared/gr-dropdown-list/gr-dropdown-list';
-
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-thread-list_html';
-import {parseDate} from '../../../utils/date-util';
-
-import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {computed, customElement, observe, property} from '@polymer/decorators';
-import {
- PolymerSpliceChange,
- PolymerDeepPropertyChange,
-} from '@polymer/polymer/interfaces';
+import {SpecialFilePath} from '../../../constants/constants';
import {
AccountDetailInfo,
AccountInfo,
- ChangeInfo,
NumericChangeId,
UrlEncodedCommentId,
} from '../../../types/common';
+import {ChangeMessageId} from '../../../api/rest-api';
import {
CommentThread,
- isDraft,
- isUnresolved,
+ getCommentAuthors,
+ hasHumanReply,
isDraftThread,
isRobotThread,
- hasHumanReply,
- getCommentAuthors,
- computeId,
- UIComment,
+ isUnresolved,
+ lastUpdated,
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
-import {assertIsDefined, assertNever} from '../../../utils/common-util';
+import {assertIsDefined} from '../../../utils/common-util';
import {CommentTabState} from '../../../types/events';
import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-
-interface CommentThreadWithInfo {
- thread: CommentThread;
- hasRobotComment: boolean;
- hasHumanReplyToRobotComment: boolean;
- unresolved: boolean;
- isEditing: boolean;
- hasDraft: boolean;
- updated?: Date;
-}
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, queryAll, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ParsedChangeInfo} from '../../../types/types';
+import {repeat} from 'lit/directives/repeat';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {getAppContext} from '../../../services/app-context';
enum SortDropdownState {
TIMESTAMP = 'Latest timestamp',
@@ -69,573 +55,507 @@
export const __testOnly_SortDropdownState = SortDropdownState;
-@customElement('gr-thread-list')
-export class GrThreadList extends PolymerElement {
- static get template() {
- return htmlTemplate;
+/**
+ * Order as follows:
+ * - Patchset level threads (descending based on patchset number)
+ * - unresolved
+ * - comments with drafts
+ * - comments without drafts
+ * - resolved
+ * - comments with drafts
+ * - comments without drafts
+ * - File name
+ * - Line number
+ * - Unresolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ * - Resolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ */
+export function compareThreads(
+ c1: CommentThread,
+ c2: CommentThread,
+ byTimestamp = false
+) {
+ if (byTimestamp) {
+ const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+ const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+ const timeDiff = c2Time - c1Time;
+ if (timeDiff !== 0) return c2Time - c1Time;
}
- @property({type: Object})
- change?: ChangeInfo;
+ if (c1.path !== c2.path) {
+ // '/PATCHSET' will not come before '/COMMIT' when sorting
+ // alphabetically so move it to the front explicitly
+ if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return -1;
+ }
+ if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return 1;
+ }
+ return c1.path.localeCompare(c2.path);
+ }
+ // Convert 'FILE' and 'LOST' to undefined.
+ const line1 = typeof c1.line === 'number' ? c1.line : undefined;
+ const line2 = typeof c2.line === 'number' ? c2.line : undefined;
+ if (line1 !== line2) {
+ // one of them is a FILE/LOST comment, show first
+ if (line1 === undefined) return -1;
+ if (line2 === undefined) return 1;
+ // Lower line numbers first.
+ return line1 < line2 ? -1 : 1;
+ }
+
+ if (c1.patchNum !== c2.patchNum) {
+ // `patchNum` should be required, but show undefined first.
+ if (c1.patchNum === undefined) return -1;
+ if (c2.patchNum === undefined) return 1;
+ // Higher patchset numbers first.
+ return c1.patchNum > c2.patchNum ? -1 : 1;
+ }
+
+ // Sorting should not be based on the thread being unresolved or being a draft
+ // thread, because that would be a surprising re-sort when the thread changes
+ // state.
+
+ const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+ const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+ if (c2Time !== c1Time) {
+ // Newer comments first.
+ return c2Time - c1Time;
+ }
+
+ return 0;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends LitElement {
+ @queryAll('gr-comment-thread')
+ threadElements?: NodeList;
+
+ /**
+ * Raw list of threads for the component to show.
+ *
+ * ATTENTION! this.threads should never be used directly within the component.
+ *
+ * Either use getAllThreads(), which applies filters that are inherent to what
+ * the component is supposed to render,
+ * e.g. onlyShowRobotCommentsWithHumanReply.
+ *
+ * Or use getDisplayedThreads(), which applies the currently selected filters
+ * on top.
+ */
@property({type: Array})
threads: CommentThread[] = [];
- @property({type: String})
- changeNum?: NumericChangeId;
-
- @property({type: Boolean})
- loggedIn?: boolean;
-
- @property({type: Array})
- _sortedThreads: CommentThread[] = [];
-
- @property({type: Boolean})
+ @property({type: Boolean, attribute: 'show-comment-context'})
showCommentContext = false;
- @property({
- computed:
- '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
- '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
- type: Array,
- })
- _displayedThreads: CommentThread[] = [];
-
- // thread-list is used in multiple places like the change log, hence
- // keeping the default to be false. When used in comments tab, it's
- // set as true.
- @property({type: Boolean})
+ /** Along with `draftsOnly` is the currently selected filter. */
+ @property({type: Boolean, attribute: 'unresolved-only'})
unresolvedOnly = false;
- @property({type: Boolean})
- _draftsOnly = false;
-
- @property({type: Boolean})
+ @property({
+ type: Boolean,
+ attribute: 'only-show-robot-comments-with-human-reply',
+ })
onlyShowRobotCommentsWithHumanReply = false;
- @property({type: Boolean})
+ @property({type: Boolean, attribute: 'hide-dropdown'})
hideDropdown = false;
- @property({type: Object, observer: '_commentTabStateChange'})
+ @property({type: Object, attribute: 'comment-tab-state'})
commentTabState?: CommentTabState;
- @property({type: Object})
- sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
-
- @property({type: Array, notify: true})
- selectedAuthors: AccountInfo[] = [];
-
- @property({type: Object})
- account?: AccountDetailInfo;
-
- @computed('unresolvedOnly', '_draftsOnly')
- get commentsDropdownValue() {
- // set initial value and triggered when comment summary chips are clicked
- if (this._draftsOnly) return CommentTabState.DRAFTS;
- return this.unresolvedOnly
- ? CommentTabState.UNRESOLVED
- : CommentTabState.SHOW_ALL;
- }
-
- @property({type: String})
+ @property({type: String, attribute: 'scroll-comment-id'})
scrollCommentId?: UrlEncodedCommentId;
- _showEmptyThreadsMessage(
- threads: CommentThread[],
- displayedThreads: CommentThread[],
- unresolvedOnly: boolean
- ) {
- if (!threads || !displayedThreads) return false;
- return !threads.length || (unresolvedOnly && !displayedThreads.length);
+ /**
+ * Optional context information when threads are being displayed for a
+ * specific change message. That influences which comments are expanded or
+ * collapsed by default.
+ */
+ @property({type: String, attribute: 'message-id'})
+ messageId?: ChangeMessageId;
+
+ @state()
+ changeNum?: NumericChangeId;
+
+ @state()
+ change?: ParsedChangeInfo;
+
+ @state()
+ account?: AccountDetailInfo;
+
+ @state()
+ selectedAuthors: AccountInfo[] = [];
+
+ @state()
+ sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+ /** Along with `unresolvedOnly` is the currently selected filter. */
+ @state()
+ draftsOnly = false;
+
+ private readonly changeModel = getAppContext().changeModel;
+
+ private readonly userModel = getAppContext().userModel;
+
+ constructor() {
+ super();
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+ subscribe(this, this.changeModel.change$, x => (this.change = x));
+ subscribe(this, this.userModel.account$, x => (this.account = x));
}
- _computeEmptyThreadsMessage(threads: CommentThread[]) {
- return !threads.length ? 'No comments' : 'No unresolved comments';
+ override willUpdate(changed: PropertyValues) {
+ if (changed.has('commentTabState')) this.onCommentTabStateUpdate();
}
- _showPartyPopper(threads: CommentThread[]) {
- return !!threads.length;
- }
-
- _computeResolvedCommentsMessage(
- threads: CommentThread[],
- displayedThreads: CommentThread[],
- unresolvedOnly: boolean,
- onlyShowRobotCommentsWithHumanReply: boolean
- ) {
- if (onlyShowRobotCommentsWithHumanReply) {
- threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+ private onCommentTabStateUpdate() {
+ switch (this.commentTabState) {
+ case CommentTabState.UNRESOLVED:
+ this.handleOnlyUnresolved();
+ break;
+ case CommentTabState.DRAFTS:
+ this.handleOnlyDrafts();
+ break;
+ case CommentTabState.SHOW_ALL:
+ this.handleAllComments();
+ break;
}
- if (unresolvedOnly && threads.length && !displayedThreads.length) {
- return `Show ${pluralize(threads.length, 'resolved comment')}`;
- }
- return '';
}
- _showResolvedCommentsButton(
- threads: CommentThread[],
- displayedThreads: CommentThread[],
- unresolvedOnly: boolean
- ) {
- return unresolvedOnly && threads.length && !displayedThreads.length;
+ static override get styles() {
+ return [
+ sharedStyles,
+ css`
+ #threads {
+ display: block;
+ }
+ gr-comment-thread {
+ display: block;
+ margin-bottom: var(--spacing-m);
+ }
+ .header {
+ align-items: center;
+ background-color: var(--background-color-primary);
+ border-bottom: 1px solid var(--border-color);
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ justify-content: left;
+ padding: var(--spacing-s) var(--spacing-l);
+ }
+ .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+ .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+ .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
+ display: block;
+ }
+ .thread-separator {
+ border-top: 1px solid var(--border-color);
+ margin-top: var(--spacing-xl);
+ }
+ .show-resolved-comments {
+ box-shadow: none;
+ padding-left: var(--spacing-m);
+ }
+ .partypopper {
+ margin-right: var(--spacing-s);
+ }
+ gr-dropdown-list {
+ --trigger-style-text-color: var(--primary-text-color);
+ --trigger-style-font-family: var(--font-family);
+ }
+ .filter-text,
+ .sort-text,
+ .author-text {
+ margin-right: var(--spacing-s);
+ color: var(--deemphasized-text-color);
+ }
+ .author-text {
+ margin-left: var(--spacing-m);
+ }
+ gr-account-label {
+ --account-max-length: 120px;
+ display: inline-block;
+ user-select: none;
+ --label-border-radius: 8px;
+ margin: 0 var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-m);
+ line-height: var(--line-height-normal);
+ cursor: pointer;
+ }
+ gr-account-label:focus {
+ outline: none;
+ }
+ gr-account-label:hover,
+ gr-account-label:hover {
+ box-shadow: var(--elevation-level-1);
+ cursor: pointer;
+ }
+ `,
+ ];
}
- _handleResolvedCommentsMessageClick() {
- this.unresolvedOnly = !this.unresolvedOnly;
+ override render() {
+ return html`
+ ${this.renderDropdown()}
+ <div id="threads" part="threads">
+ ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
+ </div>
+ `;
}
- getSortDropdownEntires() {
+ private renderDropdown() {
+ if (this.hideDropdown) return;
+ return html`
+ <div class="header">
+ <span class="sort-text">Sort By:</span>
+ <gr-dropdown-list
+ id="sortDropdown"
+ .value="${this.sortDropdownValue}"
+ @value-change="${(e: CustomEvent) =>
+ (this.sortDropdownValue = e.detail.value)}"
+ .items="${this.getSortDropdownEntries()}"
+ >
+ </gr-dropdown-list>
+ <span class="separator"></span>
+ <span class="filter-text">Filter By:</span>
+ <gr-dropdown-list
+ id="filterDropdown"
+ .value="${this.getCommentsDropdownValue()}"
+ @value-change="${this.handleCommentsDropdownValueChange}"
+ .items="${this.getCommentsDropdownEntries()}"
+ >
+ </gr-dropdown-list>
+ ${this.renderAuthorChips()}
+ </div>
+ `;
+ }
+
+ private renderEmptyThreadsMessage() {
+ const threads = this.getAllThreads();
+ const threadsEmpty = threads.length === 0;
+ const displayedEmpty = this.getDisplayedThreads().length === 0;
+ if (!displayedEmpty) return;
+ const showPopper = this.unresolvedOnly && !threadsEmpty;
+ const popper = html`<span class="partypopper">🎉</span>`;
+ const showButton = this.unresolvedOnly && !threadsEmpty;
+ const button = html`
+ <gr-button
+ class="show-resolved-comments"
+ link
+ @click="${this.handleAllComments}"
+ >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
+ >
+ `;
+ return html`
+ <div>
+ <span>
+ ${showPopper ? popper : undefined}
+ ${threadsEmpty ? 'No comments' : 'No unresolved comments'}
+ ${showButton ? button : undefined}
+ </span>
+ </div>
+ `;
+ }
+
+ private renderCommentThreads() {
+ const threads = this.getDisplayedThreads();
+ return repeat(
+ threads,
+ thread => thread.rootId,
+ (thread, index) => {
+ const isFirst =
+ index === 0 || threads[index - 1].path !== threads[index].path;
+ const separator =
+ index !== 0 && isFirst
+ ? html`<div class="thread-separator"></div>`
+ : undefined;
+ const commentThread = this.renderCommentThread(thread, isFirst);
+ return html`${separator}${commentThread}`;
+ }
+ );
+ }
+
+ private renderCommentThread(thread: CommentThread, isFirst: boolean) {
+ return html`
+ <gr-comment-thread
+ .thread="${thread}"
+ show-file-path
+ ?show-ported-comment="${thread.ported}"
+ ?show-comment-context="${this.showCommentContext}"
+ ?show-file-name="${isFirst}"
+ .messageId="${this.messageId}"
+ ?should-scroll-into-view="${thread.rootId === this.scrollCommentId}"
+ @comment-thread-editing-changed="${() => {
+ this.requestUpdate();
+ }}"
+ ></gr-comment-thread>
+ `;
+ }
+
+ private renderAuthorChips() {
+ const authors = getCommentAuthors(this.getDisplayedThreads(), this.account);
+ if (authors.length === 0) return;
+ return html`<span class="author-text">From:</span>${authors.map(author =>
+ this.renderAccountChip(author)
+ )}`;
+ }
+
+ private renderAccountChip(account: AccountInfo) {
+ const selected = this.selectedAuthors.some(
+ a => a._account_id === account._account_id
+ );
+ return html`
+ <gr-account-label
+ .account="${account}"
+ @click="${this.handleAccountClicked}"
+ selectionChipStyle
+ ?selected="${selected}"
+ ></gr-account-label>
+ `;
+ }
+
+ private getCommentsDropdownValue() {
+ if (this.draftsOnly) return CommentTabState.DRAFTS;
+ if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
+ return CommentTabState.SHOW_ALL;
+ }
+
+ private getSortDropdownEntries() {
return [
{text: SortDropdownState.FILES, value: SortDropdownState.FILES},
{text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
];
}
- getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) {
- const items: DropdownItem[] = [
- {
- text: `Unresolved (${this._countUnresolved(threads)})`,
- value: CommentTabState.UNRESOLVED,
- },
- {
- text: `All (${this._countAllThreads(threads)})`,
- value: CommentTabState.SHOW_ALL,
- },
- ];
- if (loggedIn)
- items.splice(1, 0, {
- text: `Drafts (${this._countDrafts(threads)})`,
+ // private, but visible for testing
+ getCommentsDropdownEntries() {
+ const items: DropdownItem[] = [];
+ const threads = this.getAllThreads();
+ items.push({
+ text: `Unresolved (${threads.filter(isUnresolved).length})`,
+ value: CommentTabState.UNRESOLVED,
+ });
+ if (this.account) {
+ items.push({
+ text: `Drafts (${threads.filter(isDraftThread).length})`,
value: CommentTabState.DRAFTS,
});
+ }
+ items.push({
+ text: `All (${threads.length})`,
+ value: CommentTabState.SHOW_ALL,
+ });
return items;
}
- getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) {
- return getCommentAuthors(threads, account);
- }
-
- handleAccountClicked(e: MouseEvent) {
+ private handleAccountClicked(e: MouseEvent) {
const account = (e.target as GrAccountChip).account;
assertIsDefined(account, 'account');
- const index = this.selectedAuthors.findIndex(
- author => author._account_id === account._account_id
- );
- if (index === -1) this.push('selectedAuthors', account);
- else this.splice('selectedAuthors', index, 1);
- // re-assign so that isSelected template method is called
- this.selectedAuthors = [...this.selectedAuthors];
+ const predicate = (a: AccountInfo) => a._account_id === account._account_id;
+ const found = this.selectedAuthors.find(predicate);
+ if (found) {
+ this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a));
+ } else {
+ this.selectedAuthors = [...this.selectedAuthors, account];
+ }
}
- isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
- return selectedAuthors.some(a => a._account_id === author._account_id);
- }
-
- computeShouldScrollIntoView(
- comments: UIComment[],
- scrollCommentId?: UrlEncodedCommentId
- ) {
- const comment = comments?.[0];
- if (!comment) return false;
- return computeId(comment) === scrollCommentId;
- }
-
- handleSortDropdownValueChange(e: CustomEvent) {
- this.sortDropdownValue = e.detail.value;
- /*
- * Ideally we would have updateSortedThreads observe on sortDropdownValue
- * but the method triggered re-render only when the length of threads
- * changes, hence keep the explicit resortThreads method
- */
- this.resortThreads(this.threads);
- }
-
+ // private, but visible for testing
handleCommentsDropdownValueChange(e: CustomEvent) {
const value = e.detail.value;
- if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
- else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
- else this._handleAllComments();
- }
-
- _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
- if (
- this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
- !this.hideDropdown
- ) {
- // In case of equal timestamps we want futher ordering
- if (c1.updated && c2.updated && c1.updated !== c2.updated)
- return c1.updated > c2.updated ? -1 : 1;
+ switch (value) {
+ case CommentTabState.UNRESOLVED:
+ this.handleOnlyUnresolved();
+ break;
+ case CommentTabState.DRAFTS:
+ this.handleOnlyDrafts();
+ break;
+ default:
+ this.handleAllComments();
}
-
- if (c1.thread.path !== c2.thread.path) {
- // '/PATCHSET' will not come before '/COMMIT' when sorting
- // alphabetically so move it to the front explicitly
- if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return -1;
- }
- if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return 1;
- }
- return c1.thread.path.localeCompare(c2.thread.path);
- }
-
- // Patchset comments have no line/range associated with them
- if (c1.thread.line !== c2.thread.line) {
- if (!c1.thread.line || !c2.thread.line) {
- // one of them is a file level comment, show first
- return c1.thread.line ? 1 : -1;
- }
- return c1.thread.line < c2.thread.line ? -1 : 1;
- }
-
- if (c1.thread.patchNum !== c2.thread.patchNum) {
- if (!c1.thread.patchNum) return 1;
- if (!c2.thread.patchNum) return -1;
- // Threads left on Base when comparing Base vs X have patchNum = X
- // and CommentSide = PARENT
- // Threads left on 'edit' have patchNum set as latestPatchNum
- return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
- }
-
- if (c2.unresolved !== c1.unresolved) {
- if (!c1.unresolved) return 1;
- if (!c2.unresolved) return -1;
- }
-
- if (c2.hasDraft !== c1.hasDraft) {
- if (!c1.hasDraft) return 1;
- if (!c2.hasDraft) return -1;
- }
-
- if (c2.updated !== c1.updated) {
- if (!c1.updated) return 1;
- if (!c2.updated) return -1;
- return c2.updated.getTime() - c1.updated.getTime();
- }
-
- if (c2.thread.rootId !== c1.thread.rootId) {
- if (!c1.thread.rootId) return 1;
- if (!c2.thread.rootId) return -1;
- return c1.thread.rootId.localeCompare(c2.thread.rootId);
- }
-
- return 0;
- }
-
- resortThreads(threads: CommentThread[]) {
- const threadsWithInfo = threads.map(thread =>
- this._getThreadWithStatusInfo(thread)
- );
- this._sortedThreads = threadsWithInfo
- .sort((t1, t2) => this._compareThreads(t1, t2))
- .map(threadInfo => threadInfo.thread);
}
/**
- * Observer on threads and update _sortedThreads when needed.
- * Order as follows:
- * - Patchset level threads (descending based on patchset number)
- * - unresolved
- * - comments with drafts
- * - comments without drafts
- * - resolved
- * - comments with drafts
- * - comments without drafts
- * - File name
- * - Line number
- * - Unresolved (descending based on patchset number)
- * - comments with drafts
- * - comments without drafts
- * - Resolved (descending based on patchset number)
- * - comments with drafts
- * - comments without drafts
- *
- * @param threads
- * @param spliceRecord
+ * Returns all threads that the list may show.
*/
- @observe('threads', 'threads.splices')
- _updateSortedThreads(
- threads: CommentThread[],
- _: PolymerSpliceChange<CommentThread[]>
- ) {
- if (!threads || threads.length === 0) {
- this._sortedThreads = [];
- this._displayedThreads = [];
- return;
- }
- // We only want to sort on thread additions / removals to avoid
- // re-rendering on modifications (add new reply / edit draft etc.).
- // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
- // TODO(TS): We have removed a buggy check of the splices here. A splice
- // with addedCount > 0 or removed.length > 0 should also cause re-sorting
- // and re-rendering, but apparently spliceRecord is always undefined for
- // whatever reason.
- // If there is an unsaved draftThread which is supposed to be replaced with
- // a saved draftThread then resort all threads
- const unsavedThread = this._sortedThreads.some(thread =>
- thread.rootId?.includes('draft__')
- );
- if (this._sortedThreads.length === threads.length && !unsavedThread) {
- // Instead of replacing the _sortedThreads which will trigger a re-render,
- // we override all threads inside of it.
- for (const thread of threads) {
- const idxInSortedThreads = this._sortedThreads.findIndex(
- t => t.rootId === thread.rootId
- );
- this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
- }
- return;
- }
-
- this.resortThreads(threads);
- }
-
- _computeDisplayedThreads(
- sortedThreadsRecord?: PolymerDeepPropertyChange<
- CommentThread[],
- CommentThread[]
- >,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
- return sortedThreadsRecord.base.filter(t =>
- this._shouldShowThread(
- t,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
+ // private, but visible for testing
+ getAllThreads() {
+ return this.threads.filter(
+ t =>
+ !this.onlyShowRobotCommentsWithHumanReply ||
+ !isRobotThread(t) ||
+ hasHumanReply(t)
);
}
- _isFirstThreadWithFileName(
- displayedThreads: CommentThread[],
- thread: CommentThread,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- const threads = displayedThreads.filter(t =>
- this._shouldShowThread(
- t,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
- );
- const index = threads.findIndex(t => t.rootId === thread.rootId);
- if (index === -1) {
- return false;
- }
- return index === 0 || threads[index - 1].path !== threads[index].path;
+ /**
+ * Returns all threads that are currently shown in the list, respecting the
+ * currently selected filter.
+ */
+ // private, but visible for testing
+ getDisplayedThreads() {
+ const byTimestamp =
+ this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
+ !this.hideDropdown;
+ return this.getAllThreads()
+ .sort((t1, t2) => compareThreads(t1, t2, byTimestamp))
+ .filter(t => this.shouldShowThread(t));
}
- _shouldRenderSeparator(
- displayedThreads: CommentThread[],
- thread: CommentThread,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- const threads = displayedThreads.filter(t =>
- this._shouldShowThread(
- t,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
- );
- const index = threads.findIndex(t => t.rootId === thread.rootId);
- if (index === -1) {
- return false;
- }
- return (
- index > 0 &&
- this._isFirstThreadWithFileName(
- displayedThreads,
- thread,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors
- )
+ private isASelectedAuthor(account?: AccountInfo) {
+ if (!account) return false;
+ return this.selectedAuthors.some(
+ author => account._account_id === author._account_id
);
}
- _shouldShowThread(
- thread: CommentThread,
- unresolvedOnly?: boolean,
- draftsOnly?: boolean,
- onlyShowRobotCommentsWithHumanReply?: boolean,
- selectedAuthors?: AccountInfo[]
- ) {
- if (
- [
- thread,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- selectedAuthors,
- ].includes(undefined)
- ) {
- return false;
+ private shouldShowThread(thread: CommentThread) {
+ // Never make a thread disappear while the user is editing it.
+ assertIsDefined(thread.rootId, 'thread.rootId');
+ const el = this.queryThreadElement(thread.rootId);
+ if (el?.editing) return true;
+
+ if (this.selectedAuthors.length > 0) {
+ const hasACommentFromASelectedAuthor = thread.comments.some(c =>
+ this.isASelectedAuthor(c.author)
+ );
+ if (!hasACommentFromASelectedAuthor) return false;
}
- if (selectedAuthors!.length) {
- if (
- !thread.comments.some(
- c =>
- c.author &&
- selectedAuthors!.some(
- author => c.author!._account_id === author._account_id
- )
- )
- ) {
- return false;
- }
+ // This is probably redundant, because getAllThreads() filters this out.
+ if (this.onlyShowRobotCommentsWithHumanReply) {
+ if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
}
- if (
- !draftsOnly &&
- !unresolvedOnly &&
- !onlyShowRobotCommentsWithHumanReply
- ) {
- return true;
- }
+ if (this.draftsOnly && !isDraftThread(thread)) return false;
+ if (this.unresolvedOnly && !isUnresolved(thread)) return false;
- const threadInfo = this._getThreadWithStatusInfo(thread);
-
- if (threadInfo.isEditing) {
- return true;
- }
-
- if (
- threadInfo.hasRobotComment &&
- onlyShowRobotCommentsWithHumanReply &&
- !threadInfo.hasHumanReplyToRobotComment
- ) {
- return false;
- }
-
- let filtersCheck = true;
- if (draftsOnly && unresolvedOnly) {
- filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
- } else if (draftsOnly) {
- filtersCheck = threadInfo.hasDraft;
- } else if (unresolvedOnly) {
- filtersCheck = threadInfo.unresolved;
- }
-
- return filtersCheck;
+ return true;
}
- _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
- const comments = thread.comments;
- const lastComment = comments.length
- ? comments[comments.length - 1]
- : undefined;
- const hasRobotComment = isRobotThread(thread);
- const hasHumanReplyToRobotComment =
- hasRobotComment && hasHumanReply(thread);
- let updated = undefined;
- if (lastComment) {
- if (isDraft(lastComment)) updated = lastComment.__date;
- if (lastComment.updated) updated = parseDate(lastComment.updated);
- }
-
- return {
- thread,
- hasRobotComment,
- hasHumanReplyToRobotComment,
- unresolved: !!lastComment && !!lastComment.unresolved,
- isEditing: isDraft(lastComment) && !!lastComment.__editing,
- hasDraft: !!lastComment && isDraft(lastComment),
- updated,
- };
- }
-
- _isOnParent(side?: CommentSide) {
- // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
- // classified as parent??
- return !!side;
- }
-
- _handleOnlyUnresolved() {
+ private handleOnlyUnresolved() {
this.unresolvedOnly = true;
- this._draftsOnly = false;
+ this.draftsOnly = false;
}
- _handleOnlyDrafts() {
- this._draftsOnly = true;
+ private handleOnlyDrafts() {
+ this.draftsOnly = true;
this.unresolvedOnly = false;
}
- _handleAllComments() {
- this._draftsOnly = false;
+ private handleAllComments() {
+ this.draftsOnly = false;
this.unresolvedOnly = false;
}
- _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
- return !draftsOnly && !unresolvedOnly;
- }
-
- _countUnresolved(threads?: CommentThread[]) {
- return (
- this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
- .length ?? 0
- );
- }
-
- _countAllThreads(threads?: CommentThread[]) {
- return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
- }
-
- _countDrafts(threads?: CommentThread[]) {
- return (
- this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
- .length ?? 0
- );
- }
-
- filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
- return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
- }
-
- _commentTabStateChange(
- newValue?: CommentTabState,
- oldValue?: CommentTabState
- ) {
- if (!newValue || newValue === oldValue) return;
- let focusTo: string | undefined;
- switch (newValue) {
- case CommentTabState.UNRESOLVED:
- this._handleOnlyUnresolved();
- // input is null because it's not rendered yet.
- focusTo = '#unresolvedRadio';
- break;
- case CommentTabState.DRAFTS:
- this._handleOnlyDrafts();
- focusTo = '#draftsRadio';
- break;
- case CommentTabState.SHOW_ALL:
- this._handleAllComments();
- focusTo = '#allRadio';
- break;
- default:
- assertNever(newValue, 'Unsupported preferred state');
- }
- const selector = focusTo;
- window.setTimeout(() => {
- const input = this.shadowRoot?.querySelector<HTMLInputElement>(selector);
- input?.focus();
- }, 0);
+ private queryThreadElement(rootId: string): GrCommentThread | undefined {
+ const els = [...(this.threadElements ?? [])] as GrCommentThread[];
+ return els.find(el => el.rootId === rootId);
}
}
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
deleted file mode 100644
index 3eb28c9..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- #threads {
- display: block;
- }
- gr-comment-thread {
- display: block;
- margin-bottom: var(--spacing-m);
- }
- .header {
- align-items: center;
- background-color: var(--background-color-primary);
- border-bottom: 1px solid var(--border-color);
- border-top: 1px solid var(--border-color);
- display: flex;
- justify-content: left;
- padding: var(--spacing-s) var(--spacing-l);
- }
- .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
- .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
- .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
- display: block;
- }
- .thread-separator {
- border-top: 1px solid var(--border-color);
- margin-top: var(--spacing-xl);
- }
- .show-resolved-comments {
- box-shadow: none;
- padding-left: var(--spacing-m);
- }
- .partypopper{
- margin-right: var(--spacing-s);
- }
- gr-dropdown-list {
- --trigger-style-text-color: var(--primary-text-color);
- --trigger-style-font-family: var(--font-family);
- }
- .filter-text, .sort-text, .author-text {
- margin-right: var(--spacing-s);
- color: var(--deemphasized-text-color);
- }
- .author-text {
- margin-left: var(--spacing-m);
- }
- gr-account-label {
- --account-max-length: 120px;
- display: inline-block;
- user-select: none;
- --label-border-radius: 8px;
- margin: 0 var(--spacing-xs);
- padding: var(--spacing-xs) var(--spacing-m);
- line-height: var(--line-height-normal);
- cursor: pointer;
- }
- gr-account-label:focus {
- outline: none;
- }
- gr-account-label:hover,
- gr-account-label:hover {
- box-shadow: var(--elevation-level-1);
- cursor: pointer;
- }
- </style>
- <template is="dom-if" if="[[!hideDropdown]]">
- <div class="header">
- <span class="sort-text">Sort By:</span>
- <gr-dropdown-list
- id="sortDropdown"
- value="[[sortDropdownValue]]"
- on-value-change="handleSortDropdownValueChange"
- items="[[getSortDropdownEntires()]]"
- >
- </gr-dropdown-list>
- <span class="separator"></span>
- <span class="filter-text">Filter By:</span>
- <gr-dropdown-list
- id="filterDropdown"
- value="[[commentsDropdownValue]]"
- on-value-change="handleCommentsDropdownValueChange"
- items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
- >
- </gr-dropdown-list>
- <template is="dom-if" if="[[_displayedThreads.length]]">
- <span class="author-text">From:</span>
- <template is="dom-repeat" items="[[getCommentAuthors(_displayedThreads, account)]]">
- <gr-account-label
- account="[[item]]"
- on-click="handleAccountClicked"
- selectionChipStyle
- selected="[[isSelected(item, selectedAuthors)]]"
- > </gr-account-label>
- </template>
- </template>
- </div>
- </template>
- <div id="threads" part="threads">
- <template
- is="dom-if"
- if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
- >
- <div>
- <span>
- <template is="dom-if" if="[[_showPartyPopper(threads)]]">
- <span class="partypopper">\🎉</span>
- </template>
- [[_computeEmptyThreadsMessage(threads, _displayedThreads,
- unresolvedOnly)]]
- <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
- <gr-button
- class="show-resolved-comments"
- link
- on-click="_handleResolvedCommentsMessageClick">
- [[_computeResolvedCommentsMessage(threads, _displayedThreads,
- unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
- </gr-button>
- </template>
- </span>
- </div>
- </template>
- <template
- is="dom-repeat"
- items="[[_displayedThreads]]"
- as="thread"
- initial-count="10"
- target-framerate="60"
- >
- <template
- is="dom-if"
- if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
- >
- <div class="thread-separator"></div>
- </template>
- <gr-comment-thread
- show-file-path=""
- show-ported-comment="[[thread.ported]]"
- show-comment-context="[[showCommentContext]]"
- change-num="[[changeNum]]"
- comments="[[thread.comments]]"
- diff-side="[[thread.diffSide]]"
- show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
- project-name="[[change.project]]"
- is-on-parent="[[_isOnParent(thread.commentSide)]]"
- line-num="[[thread.line]]"
- patch-num="[[thread.patchNum]]"
- path="[[thread.path]]"
- root-id="{{thread.rootId}}"
- should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
- ></gr-comment-thread>
- </template>
- </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
deleted file mode 100644
index aab5cee..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ /dev/null
@@ -1,673 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-thread-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {CommentTabState} from '../../../types/events.js';
-import {__testOnly_SortDropdownState} from './gr-thread-list.js';
-import {queryAll} from '../../../test/test-utils.js';
-import {accountOrGroupKey} from '../../../utils/account-util.js';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
-import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-thread-list');
-
-suite('gr-thread-list tests', () => {
- let element;
-
- function getVisibleThreads() {
- return [...dom(element.root)
- .querySelectorAll('gr-comment-thread')]
- .filter(e => e.style.display !== 'none');
- }
-
- setup(async () => {
- element = basicFixture.instantiate();
- element.changeNum = 123;
- element.change = {
- project: 'testRepo',
- };
- element.threads = [
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000001,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'ecf0b9fa_fe1a5f62',
- line: 5,
- updated: '1',
- message: 'test',
- unresolved: true,
- },
- {
- id: '503008e2_0ab203ee',
- path: '/COMMIT_MSG',
- line: 5,
- in_reply_to: 'ecf0b9fa_fe1a5f62',
- updated: '1',
- message: 'draft',
- unresolved: true,
- __draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 5,
- rootId: 'ecf0b9fa_fe1a5f62',
- updated: '1',
- },
- {
- comments: [
- {
- path: 'test.txt',
- author: {
- _account_id: 1000002,
- name: 'user',
- username: 'user',
- },
- patch_set: 3,
- id: '09a9fb0a_1484e6cf',
- side: 'PARENT',
- updated: '2',
- message: 'Some comment on another patchset.',
- unresolved: false,
- },
- ],
- patchNum: 3,
- path: 'test.txt',
- rootId: '09a9fb0a_1484e6cf',
- updated: '2',
- commentSide: 'PARENT',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000002,
- name: 'user',
- username: 'user',
- },
- patch_set: 2,
- id: '8caddf38_44770ec1',
- updated: '3',
- message: 'Another unresolved comment',
- unresolved: false,
- },
- ],
- patchNum: 2,
- path: '/COMMIT_MSG',
- rootId: '8caddf38_44770ec1',
- updated: '3',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000003,
- name: 'user',
- username: 'user',
- },
- patch_set: 2,
- id: 'scaddf38_44770ec1',
- line: 4,
- updated: '4',
- message: 'Yet another unresolved comment',
- unresolved: true,
- },
- ],
- patchNum: 2,
- path: '/COMMIT_MSG',
- line: 4,
- rootId: 'scaddf38_44770ec1',
- updated: '4',
- },
- {
- comments: [
- {
- id: 'zcf0b9fa_fe1a5f62',
- path: '/COMMIT_MSG',
- line: 6,
- updated: '5',
- message: 'resolved draft',
- unresolved: false,
- __draft: true,
- __draftID: '0.m683trwff69',
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 6,
- rootId: 'zcf0b9fa_fe1a5f62',
- updated: '5',
- },
- {
- comments: [
- {
- id: 'patchset_level_1',
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- updated: '6',
- message: 'patchset comment 1',
- unresolved: false,
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 2,
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- rootId: 'patchset_level_1',
- updated: '6',
- },
- {
- comments: [
- {
- id: 'patchset_level_2',
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- updated: '7',
- message: 'patchset comment 2',
- unresolved: false,
- __editing: false,
- patch_set: '3',
- },
- ],
- patchNum: 3,
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- rootId: 'patchset_level_2',
- updated: '7',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'rc1',
- line: 5,
- updated: '8',
- message: 'test',
- unresolved: true,
- robot_id: 'rc1',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 5,
- rootId: 'rc1',
- updated: '8',
- },
- {
- comments: [
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'rc2',
- line: 7,
- updated: '9',
- message: 'test',
- unresolved: true,
- robot_id: 'rc2',
- },
- {
- path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'c2_1',
- line: 5,
- updated: '10',
- message: 'test',
- unresolved: true,
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 7,
- rootId: 'rc2',
- updated: '10',
- },
- ];
-
- // use flush to render all (bypass initial-count set on dom-repeat)
- await flush();
- });
-
- test('draft dropdown item only appears when logged in', () => {
- element.loggedIn = false;
- flush();
- assert.equal(element.getCommentsDropdownEntires(element.threads,
- element.loggedIn).length, 2);
- element.loggedIn = true;
- flush();
- assert.equal(element.getCommentsDropdownEntires(element.threads,
- element.loggedIn).length, 3);
- });
-
- test('show all threads by default', () => {
- assert.equal(dom(element.root)
- .querySelectorAll('gr-comment-thread').length, element.threads.length);
- assert.equal(getVisibleThreads().length, element.threads.length);
- });
-
- test('show unresolved threads if unresolvedOnly is set', async () => {
- element.unresolvedOnly = true;
- await flush();
- const unresolvedThreads = element.threads.filter(t => t.comments.some(
- c => c.unresolved
- ));
- assert.equal(getVisibleThreads().length, unresolvedThreads.length);
- });
-
- test('showing file name takes visible threads into account', () => {
- element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
- assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
- element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
- element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
- true);
- element.unresolvedOnly = true;
- assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
- element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
- element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
- false);
- });
-
- test('onlyShowRobotCommentsWithHumanReply ', () => {
- element.onlyShowRobotCommentsWithHumanReply = true;
- flush();
- assert.equal(
- getVisibleThreads().length,
- element.threads.length - 1);
- assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
- });
-
- suite('_compareThreads', () => {
- setup(() => {
- element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
- });
-
- test('patchset comes before any other file', () => {
- const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
- const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
-
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- // assigning values to properties such that t2 should come first
- t1.patchNum = 1;
- t2.patchNum = 2;
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('file path is compared lexicographically', () => {
- const t1 = {thread: {path: 'a.txt'}};
- const t2 = {thread: {path: 'b.txt'}};
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- t1.patchNum = 1;
- t2.patchNum = 2;
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('patchset comments sorted by reverse patchset', () => {
- const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 1}};
- const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 2}};
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
-
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
-
- test('patchset comments with same patchset picks unresolved first', () => {
- const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 1}, unresolved: true};
- const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- patchNum: 1}, unresolved: false};
- t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('file level comment before line', () => {
- const t1 = {thread: {path: 'a.txt', line: 2}};
- const t2 = {thread: {path: 'a.txt'}};
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
-
- // give preference to t1 in unresolved/draft properties
- t1.unresolved = t1.hasDraft = true;
- t2.unresolved = t2.unresolved = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
-
- test('comments sorted by line', () => {
- const t1 = {thread: {path: 'a.txt', line: 2}};
- const t2 = {thread: {path: 'a.txt', line: 3}};
- t1.patchNum = t2.patchNum = 1;
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- t1.unresolved = t1.hasDraft = false;
- t2.unresolved = t2.unresolved = true;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('comments on same line sorted by reverse patchset', () => {
- const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
- const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
- t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
-
- // give preference to t1 in unresolved/draft properties
- t1.unresolved = t1.hasDraft = true;
- t2.unresolved = t2.unresolved = false;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
-
- test('comments on same line & patchset sorted by unresolved first',
- () => {
- const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: true};
- const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: false};
- t1.patchNum = t2.patchNum = 1;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
-
- t2.hasDraft = true;
- t1.hasDraft = false;
- assert.equal(element._compareThreads(t1, t2), -1);
- assert.equal(element._compareThreads(t2, t1), 1);
- });
-
- test('comments on same line & patchset & unresolved sorted by draft',
- () => {
- const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: true, hasDraft: false};
- const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
- unresolved: true, hasDraft: true};
- t1.patchNum = t2.patchNum = 1;
- assert.equal(element._compareThreads(t1, t2), 1);
- assert.equal(element._compareThreads(t2, t1), -1);
- });
- });
-
- test('_computeSortedThreads', () => {
- element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
- assert.equal(element._sortedThreads.length, 9);
- const expectedSortedRootIds = [
- 'patchset_level_2', // Posted on Patchset 3
- 'patchset_level_1', // Posted on Patchset 2
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- 'rc2', // Line 7 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- test('_computeSortedThreads with timestamp', () => {
- element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
- element.resortThreads(element.threads);
- assert.equal(element._sortedThreads.length, 9);
- const expectedSortedRootIds = [
- 'rc2',
- 'rc1',
- 'patchset_level_2',
- 'patchset_level_1',
- 'zcf0b9fa_fe1a5f62',
- 'scaddf38_44770ec1',
- '8caddf38_44770ec1',
- '09a9fb0a_1484e6cf',
- 'ecf0b9fa_fe1a5f62',
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- test('tapping single author chips', () => {
- element.account = createAccountDetailWithId(1);
- flush();
- const chips = Array.from(queryAll(element, 'gr-account-label'));
- const authors = chips.map(
- chip => accountOrGroupKey(chip.account))
- .sort();
- assert.deepEqual(authors, [1, 1000000, 1000001, 1000002, 1000003]);
- assert.equal(element.threads.length, 9);
- assert.equal(element._displayedThreads.length, 9);
-
- // accountId 1000001
- const chip = chips.find(chip => chip.account._account_id === 1000001);
-
- tap(chip);
- flush();
-
- assert.equal(element.threads.length, 9);
- assert.equal(element._displayedThreads.length, 1);
- assert.equal(element._displayedThreads[0].comments[0].author._account_id,
- 1000001);
-
- tap(chip); // tapping again resets
- flush();
- assert.equal(element.threads.length, 9);
- assert.equal(element._displayedThreads.length, 9);
- });
-
- test('tapping multiple author chips', () => {
- element.account = createAccountDetailWithId(1);
- flush();
- const chips = Array.from(queryAll(element, 'gr-account-label'));
-
- tap(chips.find(chip => chip.account._account_id === 1000001));
- tap(chips.find(chip => chip.account._account_id === 1000002));
- flush();
-
- assert.equal(element.threads.length, 9);
- assert.equal(element._displayedThreads.length, 3);
- assert.equal(element._displayedThreads[0].comments[0].author._account_id,
- 1000002);
- assert.equal(element._displayedThreads[1].comments[0].author._account_id,
- 1000002);
- assert.equal(element._displayedThreads[2].comments[0].author._account_id,
- 1000001);
- });
-
- test('thread removal and sort again', () => {
- element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
- const index = element.threads.findIndex(t => t.rootId === 'rc2');
- element.threads.splice(index, 1);
- element.threads = [...element.threads]; // trigger observers
- flush();
- assert.equal(element._sortedThreads.length, 8);
- const expectedSortedRootIds = [
- 'patchset_level_2',
- 'patchset_level_1',
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- test('modification on thread shold not trigger sort again', () => {
- element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
- const currentSortedThreads = [...element._sortedThreads];
- for (const thread of currentSortedThreads) {
- thread.comments = [...thread.comments];
- }
- const modifiedThreads = [...element.threads];
- modifiedThreads[5] = {...modifiedThreads[5]};
- modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
- ...modifiedThreads[5].comments[0],
- unresolved: false,
- }];
- element.threads = modifiedThreads;
- assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
-
- // exact same order as in _computeSortedThreads
- const expectedSortedRootIds = [
- 'patchset_level_2',
- 'patchset_level_1',
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- 'rc2', // Line 7 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- test('reset sortedThreads when threads set to undefiend', () => {
- element.threads = undefined;
- assert.deepEqual(element._sortedThreads, []);
- });
-
- test('non-equal length of sortThreads and threads' +
- ' should trigger sort again', () => {
- element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
- const modifiedThreads = [...element.threads];
- const currentSortedThreads = [...element._sortedThreads];
- element._sortedThreads = [];
- element.threads = modifiedThreads;
- assert.deepEqual(currentSortedThreads, element._sortedThreads);
-
- // exact same order as in _computeSortedThreads
- const expectedSortedRootIds = [
- 'patchset_level_2',
- 'patchset_level_1',
- '8caddf38_44770ec1', // File level on COMMIT_MSG
- 'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
- 'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
- 'rc1', // Line 5 on COMMIT_MESSAGE without drafts
- 'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
- 'rc2', // Line 7 on COMMIT_MSG
- '09a9fb0a_1484e6cf', // File level on test.txt
- ];
- element._sortedThreads.forEach((thread, index) => {
- assert.equal(thread.rootId, expectedSortedRootIds[index]);
- });
- });
-
- test('show all comments', () => {
- element.handleCommentsDropdownValueChange({detail: {
- value: CommentTabState.SHOW_ALL}});
- flush();
- assert.equal(getVisibleThreads().length, 9);
- });
-
- test('unresolved shows all unresolved comments', () => {
- element.handleCommentsDropdownValueChange({detail: {
- value: CommentTabState.UNRESOLVED}});
- flush();
- assert.equal(getVisibleThreads().length, 4);
- });
-
- test('toggle drafts only shows threads with draft comments', () => {
- element.handleCommentsDropdownValueChange({detail: {
- value: CommentTabState.DRAFTS}});
- flush();
- assert.equal(getVisibleThreads().length, 2);
- });
-
- suite('hideDropdown', () => {
- setup(async () => {
- element.hideDropdown = true;
- await flush();
- });
-
- test('toggle buttons are hidden', () => {
- assert.equal(element.shadowRoot.querySelector('.header').style.display,
- 'none');
- });
- });
-
- suite('empty thread', () => {
- setup(async () => {
- element.threads = [];
- await flush();
- });
-
- test('default empty message should show', () => {
- assert.isTrue(
- element.shadowRoot.querySelector('#threads').textContent.trim()
- .includes('No comments'));
- });
- });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
new file mode 100644
index 0000000..f6b9a81
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -0,0 +1,516 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-thread-list';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {
+ compareThreads,
+ GrThreadList,
+ __testOnly_SortDropdownState,
+} from './gr-thread-list';
+import {queryAll} from '../../../test/test-utils';
+import {accountOrGroupKey} from '../../../utils/account-util';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+ createAccountDetailWithId,
+ createParsedChange,
+ createThread,
+} from '../../../test/test-data-generators';
+import {
+ AccountId,
+ NumericChangeId,
+ PatchSetNum,
+ Timestamp,
+} from '../../../api/rest-api';
+import {RobotId, UrlEncodedCommentId} from '../../../types/common';
+import {CommentThread} from '../../../utils/comment-util';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+
+const basicFixture = fixtureFromElement('gr-thread-list');
+
+suite('gr-thread-list tests', () => {
+ let element: GrThreadList;
+
+ setup(async () => {
+ element = basicFixture.instantiate();
+ element.changeNum = 123 as NumericChangeId;
+ element.change = createParsedChange();
+ element.account = createAccountDetailWithId();
+ element.threads = [
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000001 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2015-12-01 15:15:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ {
+ id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+ path: '/COMMIT_MSG',
+ line: 5,
+ in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ updated: '2015-12-01 15:16:15.000000000' as Timestamp,
+ message: 'draft',
+ unresolved: true,
+ __draft: true,
+ patch_set: '2' as PatchSetNum,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 5,
+ rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: 'test.txt',
+ author: {
+ _account_id: 1000002 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 3 as PatchSetNum,
+ id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ updated: '2015-12-02 15:16:15.000000000' as Timestamp,
+ message: 'Some comment on another patchset.',
+ unresolved: false,
+ },
+ ],
+ patchNum: 3 as PatchSetNum,
+ path: 'test.txt',
+ rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000002 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 2 as PatchSetNum,
+ id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+ updated: '2015-12-03 15:16:15.000000000' as Timestamp,
+ message: 'Another unresolved comment',
+ unresolved: false,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000003 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 2 as PatchSetNum,
+ id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ line: 4,
+ updated: '2015-12-04 15:16:15.000000000' as Timestamp,
+ message: 'Yet another unresolved comment',
+ unresolved: true,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 4,
+ rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ path: '/COMMIT_MSG',
+ line: 6,
+ updated: '2015-12-05 15:16:15.000000000' as Timestamp,
+ message: 'resolved draft',
+ unresolved: false,
+ __draft: true,
+ patch_set: '2' as PatchSetNum,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 6,
+ rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ id: 'patchset_level_1' as UrlEncodedCommentId,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ updated: '2015-12-06 15:16:15.000000000' as Timestamp,
+ message: 'patchset comment 1',
+ unresolved: false,
+ patch_set: '2' as PatchSetNum,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ rootId: 'patchset_level_1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ id: 'patchset_level_2' as UrlEncodedCommentId,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ updated: '2015-12-07 15:16:15.000000000' as Timestamp,
+ message: 'patchset comment 2',
+ unresolved: false,
+ patch_set: '3' as PatchSetNum,
+ },
+ ],
+ patchNum: 3 as PatchSetNum,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ rootId: 'patchset_level_2' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'rc1' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2015-12-08 15:16:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ robot_id: 'rc1' as RobotId,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 5,
+ rootId: 'rc1' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ {
+ comments: [
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'rc2' as UrlEncodedCommentId,
+ line: 7,
+ updated: '2015-12-09 15:16:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ robot_id: 'rc2' as RobotId,
+ },
+ {
+ path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'c2_1' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2015-12-10 15:16:15.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 7,
+ rootId: 'rc2' as UrlEncodedCommentId,
+ commentSide: CommentSide.REVISION,
+ },
+ ];
+ await element.updateComplete;
+ });
+
+ suite('sort threads', () => {
+ test('sort all threads', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+ assert.equal(element.getDisplayedThreads().length, 9);
+ const expected: UrlEncodedCommentId[] = [
+ 'patchset_level_2' as UrlEncodedCommentId, // Posted on Patchset 3
+ 'patchset_level_1' as UrlEncodedCommentId, // Posted on Patchset 2
+ '8caddf38_44770ec1' as UrlEncodedCommentId, // File level on COMMIT_MSG
+ 'scaddf38_44770ec1' as UrlEncodedCommentId, // Line 4 on COMMIT_MSG
+ 'rc1' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE newer
+ 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE older
+ 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 6 on COMMIT_MSG
+ 'rc2' as UrlEncodedCommentId, // Line 7 on COMMIT_MSG
+ '09a9fb0a_1484e6cf' as UrlEncodedCommentId, // File level on test.txt
+ ];
+ const actual = element.getDisplayedThreads().map(t => t.rootId);
+ assert.sameOrderedMembers(actual, expected);
+ });
+
+ test('sort all threads by timestamp', () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+ assert.equal(element.getDisplayedThreads().length, 9);
+ const expected: UrlEncodedCommentId[] = [
+ 'rc2' as UrlEncodedCommentId,
+ 'rc1' as UrlEncodedCommentId,
+ 'patchset_level_2' as UrlEncodedCommentId,
+ 'patchset_level_1' as UrlEncodedCommentId,
+ 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ '8caddf38_44770ec1' as UrlEncodedCommentId,
+ '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ ];
+ const actual = element.getDisplayedThreads().map(t => t.rootId);
+ assert.sameOrderedMembers(actual, expected);
+ });
+ });
+
+ test('renders', async () => {
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="header">
+ <span class="sort-text">Sort By:</span>
+ <gr-dropdown-list id="sortDropdown"></gr-dropdown-list>
+ <span class="separator"></span>
+ <span class="filter-text">Filter By:</span>
+ <gr-dropdown-list id="filterDropdown"></gr-dropdown-list>
+ <span class="author-text">From:</span>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+ </div>
+ <div id="threads" part="threads">
+ <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <gr-comment-thread show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+ <div class="thread-separator"></div>
+ <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+ </div>
+ `);
+ });
+
+ test('renders empty', async () => {
+ element.threads = [];
+ await element.updateComplete;
+ expect(queryAndAssert(element, 'div#threads')).dom.to.equal(`
+ <div id="threads" part="threads">
+ <div><span>No comments</span></div>
+ </div>
+ `);
+ });
+
+ test('tapping single author chips', async () => {
+ element.account = createAccountDetailWithId(1);
+ await element.updateComplete;
+ const chips = Array.from(
+ queryAll<GrAccountLabel>(element, 'gr-account-label')
+ );
+ const authors = chips.map(chip => accountOrGroupKey(chip.account!)).sort();
+ assert.deepEqual(authors, [
+ 1 as AccountId,
+ 1000000 as AccountId,
+ 1000001 as AccountId,
+ 1000002 as AccountId,
+ 1000003 as AccountId,
+ ]);
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 9);
+
+ const chip = chips.find(chip => chip.account!._account_id === 1000001);
+ tap(chip!);
+ await element.updateComplete;
+
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 1);
+ assert.equal(
+ element.getDisplayedThreads()[0].comments[0].author?._account_id,
+ 1000001 as AccountId
+ );
+
+ tap(chip!);
+ await element.updateComplete;
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 9);
+ });
+
+ test('tapping multiple author chips', async () => {
+ element.account = createAccountDetailWithId(1);
+ await element.updateComplete;
+ const chips = Array.from(
+ queryAll<GrAccountLabel>(element, 'gr-account-label')
+ );
+
+ tap(chips.find(chip => chip.account?._account_id === 1000001)!);
+ tap(chips.find(chip => chip.account?._account_id === 1000002)!);
+ await element.updateComplete;
+
+ assert.equal(element.threads.length, 9);
+ assert.equal(element.getDisplayedThreads().length, 3);
+ assert.equal(
+ element.getDisplayedThreads()[0].comments[0].author?._account_id,
+ 1000002 as AccountId
+ );
+ assert.equal(
+ element.getDisplayedThreads()[1].comments[0].author?._account_id,
+ 1000002 as AccountId
+ );
+ assert.equal(
+ element.getDisplayedThreads()[2].comments[0].author?._account_id,
+ 1000001 as AccountId
+ );
+ });
+
+ test('show all comments', async () => {
+ const event = new CustomEvent('value-changed', {
+ detail: {value: CommentTabState.SHOW_ALL},
+ });
+ element.handleCommentsDropdownValueChange(event);
+ await element.updateComplete;
+ assert.equal(element.getDisplayedThreads().length, 9);
+ });
+
+ test('unresolved shows all unresolved comments', async () => {
+ const event = new CustomEvent('value-changed', {
+ detail: {value: CommentTabState.UNRESOLVED},
+ });
+ element.handleCommentsDropdownValueChange(event);
+ await element.updateComplete;
+ assert.equal(element.getDisplayedThreads().length, 4);
+ });
+
+ test('toggle drafts only shows threads with draft comments', async () => {
+ const event = new CustomEvent('value-changed', {
+ detail: {value: CommentTabState.DRAFTS},
+ });
+ element.handleCommentsDropdownValueChange(event);
+ await element.updateComplete;
+ assert.equal(element.getDisplayedThreads().length, 2);
+ });
+
+ suite('hideDropdown', () => {
+ test('header hidden for hideDropdown=true', async () => {
+ element.hideDropdown = true;
+ await element.updateComplete;
+ assert.isUndefined(query(element, '.header'));
+ });
+
+ test('header shown for hideDropdown=false', async () => {
+ element.hideDropdown = false;
+ await element.updateComplete;
+ assert.isDefined(query(element, '.header'));
+ });
+ });
+
+ suite('empty thread', () => {
+ setup(async () => {
+ element.threads = [];
+ await element.updateComplete;
+ });
+
+ test('default empty message should show', () => {
+ const threadsEl = queryAndAssert(element, '#threads');
+ assert.isTrue(threadsEl.textContent?.trim().includes('No comments'));
+ });
+ });
+});
+
+suite('compareThreads', () => {
+ let t1: CommentThread;
+ let t2: CommentThread;
+
+ const sortPredicate = (thread1: CommentThread, thread2: CommentThread) =>
+ compareThreads(thread1, thread2);
+
+ const checkOrder = (expected: CommentThread[]) => {
+ assert.sameOrderedMembers([t1, t2].sort(sortPredicate), expected);
+ assert.sameOrderedMembers([t2, t1].sort(sortPredicate), expected);
+ };
+
+ setup(() => {
+ t1 = createThread({});
+ t2 = createThread({});
+ });
+
+ test('patchset-level before file comments', () => {
+ t1.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+ t2.path = SpecialFilePath.COMMIT_MESSAGE;
+ checkOrder([t1, t2]);
+ });
+
+ test('paths lexicographically', () => {
+ t1.path = 'a.txt';
+ t2.path = 'b.txt';
+ checkOrder([t1, t2]);
+ });
+
+ test('patchsets in reverse order', () => {
+ t1.patchNum = 2 as PatchSetNum;
+ t2.patchNum = 3 as PatchSetNum;
+ checkOrder([t2, t1]);
+ });
+
+ test('file level comment before line', () => {
+ t1.line = 123;
+ t2.line = 'FILE';
+ checkOrder([t2, t1]);
+ });
+
+ test('comments sorted by line', () => {
+ t1.line = 123;
+ t2.line = 321;
+ checkOrder([t1, t2]);
+ });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index b213fa6..74d0e30 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -28,7 +28,7 @@
@property({type: Object})
eventTarget: HTMLElement | null = null;
- private checksService = getAppContext().checksService;
+ private checksModel = getAppContext().checksModel;
override connectedCallback() {
super.connectedCallback();
@@ -80,7 +80,7 @@
handleClick(e: Event) {
e.stopPropagation();
- this.checksService.triggerAction(this.action);
+ this.checksModel.triggerAction(this.action);
}
}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index ca53d28..de2099e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -32,14 +32,7 @@
Tag,
} from '../../api/checks';
import {sharedStyles} from '../../styles/shared-styles';
-import {
- CheckRun,
- checksSelectedPatchsetNumber$,
- RunResult,
- someProvidersAreLoadingSelected$,
- topLevelActionsSelected$,
- topLevelLinksSelected$,
-} from '../../services/checks/checks-model';
+import {CheckRun, RunResult} from '../../services/checks/checks-model';
import {
allResults,
firstPrimaryLink,
@@ -62,7 +55,6 @@
LabelNameToInfoMap,
PatchSetNumber,
} from '../../types/common';
-import {labels$, latestPatchNum$} from '../../services/change/change-model';
import {getAppContext} from '../../services/app-context';
import {spinnerStyles} from '../../styles/gr-spinner-styles';
import {
@@ -95,11 +87,13 @@
@state()
labels?: LabelNameToInfoMap;
- private checksService = getAppContext().checksService;
+ private changeModel = getAppContext().changeModel;
+
+ private checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, labels$, x => (this.labels = x));
+ subscribe(this, this.changeModel.labels$, x => (this.labels = x));
}
static override get styles() {
@@ -493,7 +487,7 @@
}
private handleAction(e: CustomEvent<Action>) {
- this.checksService.triggerAction(e.detail);
+ this.checksModel.triggerAction(e.detail);
}
private renderAction(action?: Action) {
@@ -537,7 +531,7 @@
@state()
repoConfig?: ConfigInfo;
- private changeService = getAppContext().changeService;
+ private changeModel = getAppContext().changeModel;
private configModel = getAppContext().configModel;
@@ -625,7 +619,7 @@
const end = pointer?.range?.end_line;
if (start) rangeText += `#${start}`;
if (end && start !== end) rangeText += `-${end}`;
- const change = this.changeService.getChange();
+ const change = this.changeModel.getChange();
assertIsDefined(change);
const path = pointer.path;
const patchset = this.result?.patchset as PatchSetNumber | undefined;
@@ -733,21 +727,35 @@
*/
private isSectionExpandedByUser = new Map<Category, boolean>();
- private readonly checksService = getAppContext().checksService;
+ private readonly changeModel = getAppContext().changeModel;
+
+ private readonly checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, topLevelActionsSelected$, x => (this.actions = x));
- subscribe(this, topLevelLinksSelected$, x => (this.links = x));
subscribe(
this,
- checksSelectedPatchsetNumber$,
+ this.checksModel.topLevelActionsSelected$,
+ x => (this.actions = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.topLevelLinksSelected$,
+ x => (this.links = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.checksSelectedPatchsetNumber$,
x => (this.checksPatchsetNumber = x)
);
- subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
subscribe(
this,
- someProvidersAreLoadingSelected$,
+ this.changeModel.latestPatchNum$,
+ x => (this.latestPatchsetNumber = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.someProvidersAreLoadingSelected$,
x => (this.someProvidersAreLoading = x)
);
}
@@ -1101,7 +1109,7 @@
}
private handleAction(e: CustomEvent<Action>) {
- this.checksService.triggerAction(e.detail);
+ this.checksModel.triggerAction(e.detail);
}
private renderAction(action?: Action) {
@@ -1112,11 +1120,11 @@
private onPatchsetSelected(e: CustomEvent<{value: string}>) {
const patchset = Number(e.detail.value);
check(!isNaN(patchset), 'selected patchset must be a number');
- this.checksService.setPatchset(patchset as PatchSetNumber);
+ this.checksModel.setPatchset(patchset as PatchSetNumber);
}
private goToLatestPatchset() {
- this.checksService.setPatchset(undefined);
+ this.checksModel.setPatchset(undefined);
}
private createPatchsetDropdownItems() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 474d2f2..20041de 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -33,11 +33,11 @@
worstCategory,
} from '../../services/checks/checks-util';
import {
- allRunsSelectedPatchset$,
CheckRun,
ChecksPatchset,
ErrorMessages,
- errorMessagesLatest$,
+} from '../../services/checks/checks-model';
+import {
fakeActions,
fakeLinks,
fakeRun0,
@@ -45,9 +45,7 @@
fakeRun2,
fakeRun3,
fakeRun4Att,
- loginCallbackLatest$,
- updateStateSetResults,
-} from '../../services/checks/checks-model';
+} from '../../services/checks/checks-fakes';
import {assertIsDefined} from '../../utils/common-util';
import {modifierPressed, whenVisible} from '../../utils/dom-util';
import {
@@ -391,13 +389,25 @@
private flagService = getAppContext().flagsService;
- private checksService = getAppContext().checksService;
+ private checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
- subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
- subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
+ subscribe(
+ this,
+ this.checksModel.allRunsSelectedPatchset$,
+ x => (this.runs = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.errorMessagesLatest$,
+ x => (this.errorMessages = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.loginCallbackLatest$,
+ x => (this.loginCallback = x)
+ );
}
static override get styles() {
@@ -619,7 +629,7 @@
link
?disabled=${runButtonDisabled}
@click="${() => {
- actions.forEach(action => this.checksService.triggerAction(action));
+ actions.forEach(action => this.checksModel.triggerAction(action));
}}"
>Run Selected</gr-button
>
@@ -659,25 +669,79 @@
}
none() {
- updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
+ this.checksModel.updateStateSetResults(
+ 'f0',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f1',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f2',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f3',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f4',
+ [],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
}
all() {
- updateStateSetResults(
+ this.checksModel.updateStateSetResults(
'f0',
[fakeRun0],
fakeActions,
fakeLinks,
ChecksPatchset.LATEST
);
- updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
- updateStateSetResults('f4', fakeRun4Att, [], [], ChecksPatchset.LATEST);
+ this.checksModel.updateStateSetResults(
+ 'f1',
+ [fakeRun1],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f2',
+ [fakeRun2],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f3',
+ [fakeRun3],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ this.checksModel.updateStateSetResults(
+ 'f4',
+ fakeRun4Att,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
}
toggle(
@@ -687,7 +751,7 @@
links: Link[] = []
) {
const newRuns = this.runs.includes(runs[0]) ? [] : runs;
- updateStateSetResults(
+ this.checksModel.updateStateSetResults(
plugin,
newRuns,
actions,
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index d1ccd11..a9c30c5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -17,16 +17,9 @@
import {LitElement, css, html, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {Action} from '../../api/checks';
-import {
- CheckResult,
- CheckRun,
- allResultsSelected$,
- checksSelectedPatchsetNumber$,
- allRunsSelectedPatchset$,
-} from '../../services/checks/checks-model';
+import {CheckResult, CheckRun} from '../../services/checks/checks-model';
import './gr-checks-runs';
import './gr-checks-results';
-import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
import {NumericChangeId, PatchSetNumber} from '../../types/common';
import {ActionTriggeredEvent} from '../../services/checks/checks-util';
import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
@@ -68,19 +61,33 @@
number | undefined
>();
- private readonly checksService = getAppContext().checksService;
+ private readonly changeModel = getAppContext().changeModel;
+
+ private readonly checksModel = getAppContext().checksModel;
constructor() {
super();
- subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
- subscribe(this, allResultsSelected$, x => (this.results = x));
subscribe(
this,
- checksSelectedPatchsetNumber$,
+ this.checksModel.allRunsSelectedPatchset$,
+ x => (this.runs = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.allResultsSelected$,
+ x => (this.results = x)
+ );
+ subscribe(
+ this,
+ this.checksModel.checksSelectedPatchsetNumber$,
x => (this.checksPatchsetNumber = x)
);
- subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
- subscribe(this, changeNum$, x => (this.changeNum = x));
+ subscribe(
+ this,
+ this.changeModel.latestPatchNum$,
+ x => (this.latestPatchsetNumber = x)
+ );
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
this.handleActionTriggered(e.detail.action, e.detail.run)
@@ -140,7 +147,7 @@
}
handleActionTriggered(action: Action, run?: CheckRun) {
- this.checksService.triggerAction(action, run);
+ this.checksModel.triggerAction(action, run);
}
handleRunSelected(e: RunSelectedEvent) {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index e82ea89..9392cb9d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -24,6 +24,7 @@
BasePatchSetNum,
EditPatchSetNum,
PatchSetNum,
+ RobotCommentInfo,
RobotId,
RobotRunId,
Timestamp,
@@ -37,7 +38,6 @@
} from '../../../test/test-data-generators';
import {createDefaultDiffPrefs} from '../../../constants/constants';
import {DiffInfo} from '../../../types/diff';
-import {UIRobot} from '../../../utils/comment-util';
import {
CloseFixPreviewEventDetail,
EventType,
@@ -50,7 +50,7 @@
suite('gr-apply-fix-dialog tests', () => {
let element: GrApplyFixDialog;
- const ROBOT_COMMENT_WITH_TWO_FIXES: UIRobot = {
+ const ROBOT_COMMENT_WITH_TWO_FIXES: RobotCommentInfo = {
id: '1' as UrlEncodedCommentId,
updated: '2018-02-08 18:49:18.000000000' as Timestamp,
robot_id: 'robot_1' as RobotId,
@@ -62,7 +62,7 @@
],
};
- const ROBOT_COMMENT_WITH_ONE_FIX: UIRobot = {
+ const ROBOT_COMMENT_WITH_ONE_FIX: RobotCommentInfo = {
id: '2' as UrlEncodedCommentId,
updated: '2018-02-08 18:49:18.000000000' as Timestamp,
robot_id: 'robot_1' as RobotId,
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 32c732e..50399be 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
@@ -15,7 +15,6 @@
* limitations under the License.
*/
import {
- CommentBasics,
PatchRange,
PatchSetNum,
RobotCommentInfo,
@@ -31,7 +30,6 @@
CommentThread,
DraftInfo,
isUnresolved,
- UIComment,
createCommentThreads,
isInPatchRange,
isDraftThread,
@@ -41,7 +39,7 @@
addPath,
} from '../../../utils/comment-util';
import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
import {pluralize} from '../../../utils/string-util';
import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
@@ -114,7 +112,7 @@
* patchNum and basePatchNum properties to represent the range.
*/
getPaths(patchRange?: PatchRange): CommentMap {
- const responses: {[path: string]: UIComment[]}[] = [
+ const responses: {[path: string]: Comment[]}[] = [
this._comments,
this.drafts,
this._robotComments,
@@ -139,25 +137,11 @@
}
/**
- * Gets all the comments for a particular thread group. Used for refreshing
- * comments after the thread group has already been built.
- */
- getCommentsForThread(rootId: UrlEncodedCommentId) {
- const allThreads = this.getAllThreadsForChange();
- const threadMatch = allThreads.find(t => t.rootId === rootId);
-
- // In the event that a single draft comment was removed by the thread-list
- // and the diff view is updating comments, there will no longer be a thread
- // found. In this case, return null.
- return threadMatch ? threadMatch.comments : null;
- }
-
- /**
* Gets all the comments and robot comments for the given change.
*/
getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
const paths = this.getPaths();
- const publishedComments: {[path: string]: CommentBasics[]} = {};
+ const publishedComments: {[path: string]: CommentInfo[]} = {};
for (const path of Object.keys(paths)) {
publishedComments[path] = this.getAllCommentsForPath(
path,
@@ -191,8 +175,8 @@
path: string,
patchNum?: PatchSetNum,
includeDrafts?: boolean
- ): Comment[] {
- const comments: Comment[] = this._comments[path] || [];
+ ): CommentInfo[] {
+ const comments: CommentInfo[] = this._comments[path] || [];
const robotComments = this._robotComments[path] || [];
let allComments = comments.concat(robotComments);
if (includeDrafts) {
@@ -228,43 +212,18 @@
return allComments;
}
- cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | undefined) {
- return new ChangeComments(
- this._comments,
- this._robotComments,
- drafts,
- this._portedComments,
- this._portedDrafts
- );
- }
-
- cloneWithUpdatedPortedComments(
- portedComments?: PathToCommentsInfoMap,
- portedDrafts?: PathToCommentsInfoMap
- ) {
- return new ChangeComments(
- this._comments,
- this._robotComments,
- this._drafts,
- portedComments,
- portedDrafts
- );
- }
-
/**
* Get the drafts for a path and optional patch num.
*
* This will return a shallow copy of all drafts every time,
* so changes on any copy will not affect other copies.
*/
- getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
- let comments = this._drafts[path] || [];
+ getAllDraftsForPath(path: string, patchNum?: PatchSetNum): DraftInfo[] {
+ let drafts = this._drafts[path] || [];
if (patchNum) {
- comments = comments.filter(c => c.patch_set === patchNum);
+ drafts = drafts.filter(c => c.patch_set === patchNum);
}
- return comments.map(c => {
- return {...c, __draft: true};
- });
+ return drafts;
}
/**
@@ -272,7 +231,7 @@
*
* // TODO(taoalpha): maybe merge in *ForPath
*/
- getAllDraftsForFile(file: PatchSetFile): Comment[] {
+ getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
if (file.basePath) {
allDrafts = allDrafts.concat(
@@ -292,8 +251,8 @@
* @param projectConfig Optional project config object to
* include in the meta sub-object.
*/
- getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
- let comments: Comment[] = [];
+ getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
+ let comments: CommentInfo[] = [];
let drafts: DraftInfo[] = [];
let robotComments: RobotCommentInfo[] = [];
if (this._comments && this._comments[path]) {
@@ -306,17 +265,13 @@
robotComments = this._robotComments[path];
}
- drafts.forEach(d => {
- d.__draft = true;
- });
-
- return comments
- .concat(drafts)
- .concat(robotComments)
+ const all = comments.concat(drafts).concat(robotComments);
+ const final = all
.filter(c => isInPatchRange(c, patchRange))
.map(c => {
return {...c};
});
+ return final;
}
/**
@@ -367,7 +322,7 @@
// ported comments will involve comments that may not belong to the
// current patchrange, so we need to form threads for them using all
// comments
- const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+ const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
return createCommentThreads(allComments).filter(thread => {
// Robot comments and drafts are not ported over. A human reply to
@@ -398,7 +353,6 @@
return false;
}
- thread.diffSide = Side.RIGHT;
if (thread.commentSide === CommentSide.PARENT) {
// TODO(dhruvsri): Add handling for merge parents
if (
@@ -406,7 +360,6 @@
!!thread.mergeParentNum
)
return false;
- thread.diffSide = Side.LEFT;
}
if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
@@ -423,8 +376,7 @@
patchRange: PatchRange
): CommentThread[] {
const threads = createCommentThreads(
- this.getCommentsForFile(file, patchRange),
- patchRange
+ this.getCommentsForFile(file, patchRange)
);
threads.push(...this._getPortedCommentThreads(file, patchRange));
return threads;
@@ -442,7 +394,10 @@
* @param projectConfig Optional project config object to
* include in the meta sub-object.
*/
- getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+ getCommentsForFile(
+ file: PatchSetFile,
+ patchRange: PatchRange
+ ): CommentInfo[] {
const comments = this.getCommentsForPath(file.path, patchRange);
if (file.basePath) {
comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -464,11 +419,11 @@
file: PatchSetFile | PatchNumOnly,
ignorePatchsetLevelComments?: boolean
) {
- let comments: Comment[] = [];
+ let comments: CommentInfo[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
} else {
- comments = this._commentObjToArray(
+ comments = this._commentObjToArray<CommentInfo>(
this.getAllPublishedComments(file.patchNum)
);
}
@@ -579,8 +534,8 @@
file: PatchSetFile | PatchNumOnly,
ignorePatchsetLevelComments?: boolean
) {
- let comments: Comment[] = [];
- let drafts: Comment[] = [];
+ let comments: CommentInfo[] = [];
+ let drafts: CommentInfo[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 7e01371..9770261 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -20,7 +20,7 @@
import {ChangeComments} from './gr-comment-api.js';
import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
-import {CommentSide, Side} from '../../../constants/constants.js';
+import {CommentSide} from '../../../constants/constants.js';
import {stubRestApi} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-comment-api');
@@ -207,7 +207,6 @@
{path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
assert.equal(portedThreads.length, 1);
assert.equal(portedThreads[0].line, 31);
- assert.equal(portedThreads[0].diffSide, Side.LEFT);
assert.equal(changeComments._getPortedCommentThreads(
{path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
@@ -363,6 +362,7 @@
...createComment(),
id: '01',
patch_set: 2,
+ path: 'file/one',
side: PARENT,
line: 1,
updated: makeTime(1),
@@ -379,6 +379,7 @@
id: '02',
in_reply_to: '04',
patch_set: 2,
+ path: 'file/one',
unresolved: true,
line: 1,
updated: makeTime(3),
@@ -388,6 +389,7 @@
...createComment(),
id: '03',
patch_set: 2,
+ path: 'file/one',
side: PARENT,
line: 2,
updated: makeTime(1),
@@ -397,6 +399,7 @@
...createComment(),
id: '04',
patch_set: 2,
+ path: 'file/one',
line: 1,
updated: makeTime(1),
};
@@ -470,6 +473,7 @@
side: PARENT,
line: 1,
updated: makeTime(3),
+ path: 'file/one',
};
commentObjs['13'] = {
@@ -481,6 +485,7 @@
// Draft gets lower timestamp than published comment, because we
// want to test that the draft still gets sorted to the end.
updated: makeTime(2),
+ path: 'file/one',
};
commentObjs['14'] = {
@@ -597,10 +602,6 @@
const path = 'file/one';
const drafts = element._changeComments.getAllDraftsForPath(path);
assert.equal(drafts.length, 2);
- const aCopyOfDrafts = element._changeComments
- .getAllDraftsForPath(path);
- assert.deepEqual(drafts, aCopyOfDrafts);
- assert.notEqual(drafts[0], aCopyOfDrafts[0]);
});
test('computeUnresolvedNum', () => {
@@ -828,24 +829,6 @@
const threads = element._changeComments.getAllThreadsForChange();
assert.deepEqual(threads, expectedThreads);
});
-
- test('getCommentsForThreadGroup', () => {
- let expectedComments = [
- {...commentObjs['04'], path: 'file/one'},
- {...commentObjs['02'], path: 'file/one'},
- {...commentObjs['13'], path: 'file/one'},
- ];
- assert.deepEqual(element._changeComments.getCommentsForThread('04'),
- expectedComments);
-
- expectedComments = [{...commentObjs['12'], path: 'file/one'}];
-
- assert.deepEqual(element._changeComments.getCommentsForThread('12'),
- expectedComments);
-
- assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
- null);
- });
});
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index f8fb40c..25a1a00 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -25,9 +25,7 @@
import {
anyLineTooLong,
getLine,
- getRange,
getSide,
- rangesEqual,
SYNTAX_MAX_LINE_LENGTH,
} from '../gr-diff/gr-diff-utils';
import {getAppContext} from '../../../services/app-context';
@@ -37,7 +35,11 @@
isMergeParent,
isNumber,
} from '../../../utils/patch-set-util';
-import {CommentThread} from '../../../utils/comment-util';
+import {
+ CommentThread,
+ isInBaseOfPatchRange,
+ isInRevisionOfPatchRange,
+} from '../../../utils/comment-util';
import {customElement, observe, property} from '@polymer/decorators';
import {
CommitRange,
@@ -83,7 +85,6 @@
import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
import {Timing} from '../../../constants/reporting';
-import {changeComments$} from '../../../services/comments/comments-model';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
import {Subscription} from 'rxjs';
import {DisplayLine, RenderPreferences} from '../../../api/diff';
@@ -266,6 +267,8 @@
private readonly browserModel = getAppContext().browserModel;
+ private readonly commentsModel = getAppContext().commentsModel;
+
private readonly reporting = getAppContext().reportingService;
private readonly flags = getAppContext().flagsService;
@@ -288,7 +291,7 @@
// change in some way, and that we should update any models we may want
// to keep in sync.
'create-comment',
- e => this._handleCreateComment(e)
+ e => this._handleCreateThread(e)
);
this.addEventListener('render-start', () => this._handleRenderStart());
this.addEventListener('render-content', () => this._handleRenderContent());
@@ -318,7 +321,7 @@
this._loggedIn = loggedIn;
});
this.subscriptions.push(
- changeComments$.subscribe(changeComments => {
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this.changeComments = changeComments;
})
);
@@ -734,30 +737,29 @@
}
_threadsChanged(threads: CommentThread[]) {
- const threadEls = new Set<GrCommentThread>();
const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
for (const threadEl of this.getThreadEls()) {
if (threadEl.rootId) {
rootIdToThreadEl.set(threadEl.rootId, threadEl);
}
}
+ const dontRemove = new Set<GrCommentThread>();
for (const thread of threads) {
const existingThreadEl =
thread.rootId && rootIdToThreadEl.get(thread.rootId);
if (existingThreadEl) {
- this._updateThreadElement(existingThreadEl, thread);
- threadEls.add(existingThreadEl);
+ existingThreadEl.thread = thread;
+ dontRemove.add(existingThreadEl);
} else {
const threadEl = this._createThreadElement(thread);
this._attachThreadElement(threadEl);
- threadEls.add(threadEl);
+ dontRemove.add(threadEl);
}
}
// Remove all threads that are no longer existing.
for (const threadEl of this.getThreadEls()) {
- if (threadEls.has(threadEl)) continue;
- const parent = threadEl.parentNode;
- if (parent) parent.removeChild(threadEl);
+ if (dontRemove.has(threadEl)) continue;
+ threadEl.remove();
}
const portedThreadsCount = threads.filter(thread => thread.ported).length;
const portedThreadsWithoutRange = threads.filter(
@@ -785,10 +787,10 @@
);
}
- _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+ _handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
if (!this.patchRange) throw Error('patch range not set');
- const {lineNum, side, range, path} = e.detail;
+ const {lineNum, side, range} = e.detail;
// Usually, the comment is stored on the patchset shown on the side the
// user added the comment on, and the commentSide will be REVISION.
@@ -806,18 +808,27 @@
? CommentSide.PARENT
: CommentSide.REVISION;
if (!this.canCommentOnPatchSetNum(patchNum)) return;
- const threadEl = this._getOrCreateThread({
+ const path =
+ this.file?.basePath &&
+ side === Side.LEFT &&
+ commentSide === CommentSide.REVISION
+ ? this.file?.basePath
+ : this.path;
+ assertIsDefined(path, 'path');
+
+ const newThread: CommentThread = {
+ rootId: undefined,
comments: [],
- path,
- diffSide: side,
- commentSide,
patchNum,
+ commentSide,
+ // TODO: Maybe just compute from patchRange.base on the fly?
+ mergeParentNum: this._parentIndex ?? undefined,
+ path,
line: lineNum,
range,
- });
- threadEl.addOrEditDraft(lineNum, range);
-
- this.reporting.recordDraftInteraction();
+ };
+ const el = this._createThreadElement(newThread);
+ this._attachThreadElement(el);
}
private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
@@ -846,21 +857,6 @@
return true;
}
- /**
- * Gets or creates a comment thread at a given location.
- * May provide a range, to get/create a range comment.
- */
- _getOrCreateThread(thread: CommentThread): GrCommentThread {
- let threadEl = this._getThreadEl(thread);
- if (!threadEl) {
- threadEl = this._createThreadElement(thread);
- this._attachThreadElement(threadEl);
- } else {
- this._updateThreadElement(threadEl, thread);
- }
- return threadEl;
- }
-
_attachThreadElement(threadEl: Element) {
this.$.diff.appendChild(threadEl);
}
@@ -873,67 +869,38 @@
}
_createThreadElement(thread: CommentThread) {
+ assertIsDefined(this.patchRange, 'patchRange');
+ const commentProps = {
+ patch_set: thread.patchNum,
+ side: thread.commentSide,
+ parent: thread.mergeParentNum,
+ };
+ let diffSide: Side;
+ if (isInBaseOfPatchRange(commentProps, this.patchRange)) {
+ diffSide = Side.LEFT;
+ } else if (isInRevisionOfPatchRange(commentProps, this.patchRange)) {
+ diffSide = Side.RIGHT;
+ } else {
+ const propsStr = JSON.stringify(commentProps);
+ const rangeStr = JSON.stringify(this.patchRange);
+ throw new Error(`comment ${propsStr} not in range ${rangeStr}`);
+ }
+
const threadEl = document.createElement('gr-comment-thread');
threadEl.className = 'comment-thread';
- threadEl.setAttribute(
- 'slot',
- `${thread.diffSide}-${thread.line || 'LOST'}`
- );
- this._updateThreadElement(threadEl, thread);
- return threadEl;
- }
-
- _updateThreadElement(threadEl: GrCommentThread, thread: CommentThread) {
- threadEl.comments = thread.comments;
- threadEl.diffSide = thread.diffSide;
- threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
- threadEl.parentIndex = this._parentIndex;
- // Use path before renmaing when comment added on the left when comparing
- // two patch sets (not against base)
- if (
- this.file &&
- this.file.basePath &&
- thread.diffSide === Side.LEFT &&
- !threadEl.isOnParent
- ) {
- threadEl.path = this.file.basePath;
- } else {
- threadEl.path = this.path;
- }
- threadEl.changeNum = this.changeNum;
- threadEl.patchNum = thread.patchNum;
+ threadEl.rootId = thread.rootId;
+ threadEl.thread = thread;
threadEl.showPatchset = false;
threadEl.showPortedComment = !!thread.ported;
- if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
- // GrCommentThread does not understand 'FILE', but requires undefined.
- else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
- threadEl.projectName = this.projectName;
- threadEl.range = thread.range;
- }
-
- /**
- * Gets a comment thread element at a given location.
- * May provide a range, to get a range comment.
- */
- _getThreadEl(thread: CommentThread): GrCommentThread | null {
- let line: LineInfo;
- if (thread.diffSide === Side.LEFT) {
- line = {beforeNumber: thread.line};
- } else if (thread.diffSide === Side.RIGHT) {
- line = {afterNumber: thread.line};
- } else {
- throw new Error(`Unknown side: ${thread.diffSide}`);
+ // These attributes are the "interface" between comment threads and gr-diff.
+ // <gr-comment-thread> does not care about them and is not affected by them.
+ threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+ threadEl.setAttribute('diff-side', `${diffSide}`);
+ threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+ if (thread.range) {
+ threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
}
- function matchesRange(threadEl: GrCommentThread) {
- return rangesEqual(getRange(threadEl), thread.range);
- }
-
- const filteredThreadEls = this._filterThreadElsForLocation(
- this.getThreadEls(),
- line,
- thread.diffSide
- ).filter(matchesRange);
- return filteredThreadEls.length ? filteredThreadEls[0] : null;
+ return threadEl;
}
_filterThreadElsForLocation(
@@ -1181,8 +1148,6 @@
'normalize-range': CustomEvent;
'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
'create-comment': CustomEvent;
- 'comment-update': CustomEvent;
- 'comment-save': CustomEvent;
'root-id-changed': CustomEvent;
}
}
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 dd15462..6149c82 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
@@ -948,7 +948,6 @@
});
test('creates comments if they do not exist yet', () => {
- const diffSide = Side.LEFT;
element.patchRange = {
basePatchNum: 'PARENT',
patchNum: 2,
@@ -957,7 +956,7 @@
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
lineNum: 3,
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
},
}));
@@ -966,10 +965,10 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.isTrue(threads[0].isOnParent);
- assert.equal(threads[0].range, undefined);
- assert.equal(threads[0].patchNum, 2);
+ assert.equal(threads[0].thread.commentSide, 'PARENT');
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[0].thread.range, undefined);
+ assert.equal(threads[0].thread.patchNum, 2);
// Try to fetch a thread with a different range.
const range = {
@@ -986,7 +985,7 @@
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
lineNum: 1,
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
range,
},
@@ -996,10 +995,10 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 2);
- assert.equal(threads[1].diffSide, diffSide);
- assert.isTrue(threads[0].isOnParent);
- assert.equal(threads[1].range, range);
- assert.equal(threads[1].patchNum, 3);
+ assert.equal(threads[0].thread.commentSide, 'PARENT');
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[1].thread.range, range);
+ assert.equal(threads[1].thread.patchNum, 3);
});
test('should not be on parent if on the right', () => {
@@ -1014,10 +1013,11 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isFalse(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'REVISION');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
});
test('should be on parent if right and base is PARENT', () => {
@@ -1032,10 +1032,11 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isTrue(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'PARENT');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
});
test('should be on parent if right and base negative', () => {
@@ -1050,10 +1051,11 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isTrue(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'PARENT');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
});
test('should not be on parent otherwise', () => {
@@ -1068,24 +1070,25 @@
},
}));
- const thread = dom(element.$.diff)
+ const threadEl = dom(element.$.diff)
.queryDistributedElements('gr-comment-thread')[0];
- assert.isFalse(thread.isOnParent);
+ assert.equal(threadEl.thread.commentSide, 'REVISION');
+ assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
});
test('thread should use old file path if first created ' +
- 'on patch set (left) before renaming', () => {
- const diffSide = Side.LEFT;
+ 'on patch set (left) before renaming', async () => {
element.patchRange = {
basePatchNum: 2,
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
},
}));
@@ -1094,22 +1097,22 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.equal(threads[0].path, element.file.basePath);
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[0].thread.path, element.file.basePath);
});
- test('thread should use new file path if first created' +
- 'on patch set (right) after renaming', () => {
- const diffSide = Side.RIGHT;
+ test('thread should use new file path if first created ' +
+ 'on patch set (right) after renaming', async () => {
element.patchRange = {
basePatchNum: 2,
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
- side: diffSide,
+ side: Side.RIGHT,
path: '/p',
},
}));
@@ -1118,23 +1121,27 @@
.queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.equal(threads[0].path, element.file.path);
+ assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+ assert.equal(threads[0].thread.path, element.file.path);
});
- test('multiple threads created on the same range', () => {
+ test('multiple threads created on the same range', async () => {
element.patchRange = {
basePatchNum: 2,
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
- const comment = createComment();
- comment.range = {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 2,
+ const comment = {
+ ...createComment(),
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 2,
+ },
+ patch_set: 3,
};
const thread = createCommentThread([comment]);
element.threads = [thread];
@@ -1159,18 +1166,18 @@
assert.equal(threads.length, 2);
});
- test('thread should use new file path if first created' +
- 'on patch set (left) but is base', () => {
- const diffSide = Side.LEFT;
+ test('thread should use new file path if first created ' +
+ 'on patch set (left) but is base', async () => {
element.patchRange = {
basePatchNum: 'PARENT',
patchNum: 3,
};
element.file = {basePath: 'file_renamed.txt', path: element.path};
+ await flush();
element.dispatchEvent(new CustomEvent('create-comment', {
detail: {
- side: diffSide,
+ side: Side.LEFT,
path: '/p',
},
}));
@@ -1179,8 +1186,8 @@
dom(element.$.diff).queryDistributedElements('gr-comment-thread');
assert.equal(threads.length, 1);
- assert.equal(threads[0].diffSide, diffSide);
- assert.equal(threads[0].path, element.file.path);
+ assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+ assert.equal(threads[0].thread.path, element.file.path);
});
test('cannot create thread on an edit', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 50d6b24..e527134 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -113,20 +113,10 @@
import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
import {CursorMoveResult} from '../../../api/core';
import {isFalse, throttleWrap, until} from '../../../utils/async-util';
-import {
- changeComments$,
- commentsLoading$,
-} from '../../../services/comments/comments-model';
import {filter, take} from 'rxjs/operators';
import {Subscription, combineLatest} from 'rxjs';
import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {
- diffPath$,
- currentPatchNum$,
- change$,
- changeLoadingStatus$,
- LoadingStatus,
-} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../services/change/change-model';
import {DisplayLine} from '../../../api/diff';
import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
@@ -368,14 +358,14 @@
// Private but used in tests.
readonly userModel = getAppContext().userModel;
- private readonly changeService = getAppContext().changeService;
+ // Private but used in tests.
+ readonly changeModel = getAppContext().changeModel;
// Private but used in tests.
readonly browserModel = getAppContext().browserModel;
- // We just want to make sure that CommentsService is instantiated.
- // Otherwise subscribing to the model won't emit any data.
- private readonly _commentsService = getAppContext().commentsService;
+ // Private but used in tests.
+ readonly commentsModel = getAppContext().commentsModel;
private readonly shortcuts = getAppContext().shortcutsService;
@@ -387,11 +377,6 @@
private subscriptions: Subscription[] = [];
- constructor() {
- super();
- this._commentsService;
- }
-
override connectedCallback() {
super.connectedCallback();
this._throttledToggleFileReviewed = throttleWrap(_ =>
@@ -405,7 +390,7 @@
});
this.subscriptions.push(
- changeComments$.subscribe(changeComments => {
+ this.commentsModel.changeComments$.subscribe(changeComments => {
this._changeComments = changeComments;
})
);
@@ -421,7 +406,7 @@
})
);
this.subscriptions.push(
- change$.subscribe(change => {
+ this.changeModel.change$.subscribe(change => {
// The diff view is tied to a specfic change number, so don't update
// _change to undefined.
if (change) this._change = change;
@@ -433,12 +418,12 @@
// properties since the method will be called anytime a property updates
// but we only want to call this on the initial load.
this.subscriptions.push(
- combineLatest(
- currentPatchNum$,
+ combineLatest([
+ this.changeModel.currentPatchNum$,
routerView$,
- diffPath$,
- this.userModel.diffPreferences$
- )
+ this.changeModel.diffPath$,
+ this.userModel.diffPreferences$,
+ ])
.pipe(
filter(
([currentPatchNum, routerView, path, diffPrefs]) =>
@@ -453,7 +438,9 @@
this.setReviewedStatus(currentPatchNum!, path!, diffPrefs);
})
);
- this.subscriptions.push(diffPath$.subscribe(path => (this._path = path)));
+ this.subscriptions.push(
+ this.changeModel.diffPath$.subscribe(path => (this._path = path))
+ );
this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
this.cursor.replaceDiffs([this.$.diffHost]);
this._onRenderHandler = (_: Event) => {
@@ -1069,7 +1056,7 @@
GerritNav.navigateToChange(this._change);
return;
}
- this.changeService.updatePath(comment.path);
+ this.changeModel.updatePath(comment.path);
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
if (!latestPatchNum) throw new Error('Missing _allPatchSets');
@@ -1079,7 +1066,7 @@
this._focusLineNum = comment.line;
} else {
if (this.params.path) {
- this.changeService.updatePath(this.params.path);
+ this.changeModel.updatePath(this.params.path);
}
if (this.params.patchNum) {
this._patchRange = {
@@ -1145,7 +1132,7 @@
}
this._files = {sortedFileList: [], changeFilesByPath: {}};
- this.changeService.updatePath(undefined);
+ this.changeModel.updatePath(undefined);
this._patchRange = undefined;
this._commitRange = undefined;
this._focusLineNum = undefined;
@@ -1169,11 +1156,15 @@
}
const promises: Promise<unknown>[] = [];
- if (!this._change)
+ if (!this._change) {
promises.push(
- until(changeLoadingStatus$, status => status === LoadingStatus.LOADED)
+ until(
+ this.changeModel.changeLoadingStatus$,
+ status => status === LoadingStatus.LOADED
+ )
);
- promises.push(until(commentsLoading$, isFalse));
+ }
+ promises.push(until(this.commentsModel.commentsLoading$, isFalse));
promises.push(
this._getChangeEdit().then(edit => {
if (edit) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index d47a03e..4c4361d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -31,8 +31,6 @@
import {EditPatchSetNum} from '../../../types/common.js';
import {CursorMoveResult} from '../../../api/core.js';
import {Side} from '../../../api/diff.js';
-import {_testOnly_setState as setChangeModelState} from '../../../services/change/change-model.js';
-import {_testOnly_setState as setCommentState} from '../../../services/comments/comments-model.js';
const basicFixture = fixtureFromElement('gr-diff-view');
@@ -93,7 +91,7 @@
]});
await flush();
- setCommentState({
+ element.commentsModel.setState({
comments: {},
robotComments: {},
drafts: {},
@@ -139,14 +137,15 @@
sinon.stub(element.reporting, 'diffViewDisplayed');
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.spy(element, '_paramsChanged');
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
});
test('comment url resolves to comment.patch_set vs latest', () => {
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -220,7 +219,7 @@
test('unchanged diff X vs latest from comment links navigates to base vs X'
, () => {
const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -248,10 +247,11 @@
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.stub(element, '_isFileUnchanged').returns(true);
sinon.spy(element, '_paramsChanged');
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
@@ -272,7 +272,7 @@
test('unchanged diff Base vs latest from comment does not navigate'
, () => {
const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -300,10 +300,11 @@
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.stub(element, '_isFileUnchanged').returns(true);
sinon.spy(element, '_paramsChanged');
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
@@ -352,7 +353,7 @@
});
test('diff toast to go to latest is shown and not base', async () => {
- setCommentState({
+ element.commentsModel.setState({
comments: {
'/COMMIT_MSG': [
{
@@ -381,10 +382,11 @@
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.spy(element, '_paramsChanged');
element._change = undefined;
- setChangeModelState({change: {
- ...createChange(),
- revisions: createRevisions(11),
- }});
+ element.changeModel.setState({
+ change: {
+ ...createChange(),
+ revisions: createRevisions(11),
+ }});
element._patchRange = {
patchNum: 2,
basePatchNum: 1,
@@ -1200,7 +1202,9 @@
manual_review: true,
};
element.userModel.setDiffPreferences(diffPreferences);
- setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+ element.changeModel.setState({
+ change: createChange(),
+ diffPath: '/COMMIT_MSG'});
setRouterModelState({
changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
@@ -1237,7 +1241,8 @@
manual_review: false,
};
element.userModel.setDiffPreferences(diffPreferences);
- setChangeModelState({change: createChange(),
+ element.changeModel.setState({
+ change: createChange(),
diffPath: '/COMMIT_MSG'});
setRouterModelState({
@@ -1262,7 +1267,9 @@
sinon.stub(element.$.diffHost, 'reload');
element.userModel.setDiffPreferences(createDefaultDiffPrefs());
- setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
+ element.changeModel.setState({
+ change: createChange(),
+ diffPath: '/COMMIT_MSG'});
setRouterModelState({
changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 7393606..63db013 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -124,7 +124,7 @@
// For Gerrit these are instances of GrCommentThread, but other gr-diff users
// have different HTML elements in use for comment threads.
// TODO: Also document the required HTML attributes that thread elements must
-// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
+// have, e.g. 'diff-side', 'range', 'line-num'.
export interface GrDiffThreadElement extends HTMLElement {
rootId: string;
}
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 3a2def0..71fee62 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -51,7 +51,6 @@
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators';
import {subscribe} from '../../lit/subscription-controller';
-import {changeComments$} from '../../../services/comments/comments-model';
// Maximum length for patch set descriptions.
const PATCH_DESC_MAX_LENGTH = 500;
@@ -127,9 +126,15 @@
private readonly reporting: ReportingService =
getAppContext().reportingService;
+ private readonly commentsModel = getAppContext().commentsModel;
+
constructor() {
super();
- subscribe(this, changeComments$, x => (this.changeComments = x));
+ subscribe(
+ this,
+ this.commentsModel.changeComments$,
+ x => (this.changeComments = x)
+ );
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 81be728..e879078 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -211,10 +211,8 @@
return Promise.all(promises);
}
- _getChangeDetail(changeNum: NumericChangeId) {
- return this.restApiService.getChangeDetail(changeNum).then(change => {
- this._change = change;
- });
+ async _getChangeDetail(changeNum: NumericChangeId) {
+ this._change = await this.restApiService.getChangeDetail(changeNum);
}
_editChange(value?: ParsedChangeInfo | null) {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index f276051..07f3851 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -16,6 +16,7 @@
*/
import '../../../test/common-test-setup-karma';
+import './gr-editor-view';
import {GrEditorView} from './gr-editor-view';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {HttpMethod} from '../../../constants/constants';
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index de749df..d0525ea 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -26,10 +26,11 @@
import {page} from '../utils/page-wrapper-utils';
import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {AppContext} from '../services/app-context';
-export function initGlobalVariables() {
+export function initGlobalVariables(appContext: AppContext) {
window.GrAnnotation = GrAnnotation;
window.page = page;
window.GrPluginActionContext = GrPluginActionContext;
- initGerritPluginApi();
+ initGerritPluginApi(appContext);
}
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
deleted file mode 100644
index c63df04..0000000
--- a/polygerrit-ui/app/elements/gr-app-init.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {createAppContext} from '../services/app-context-init';
-import {
- initVisibilityReporter,
- initPerformanceReporter,
- initErrorReporter,
-} from '../services/gr-reporting/gr-reporting_impl';
-import {injectAppContext} from '../services/app-context';
-
-const appContext = createAppContext();
-injectAppContext(appContext);
-const reportingService = appContext.reportingService;
-initVisibilityReporter(reportingService);
-initPerformanceReporter(reportingService);
-initErrorReporter(reportingService);
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 463fab9..a8da03a 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -16,7 +16,6 @@
*/
import {safeTypesBridge} from '../utils/safe-types-util';
-import './gr-app-init';
import './font-roboto-local-loader';
// Sets up global Polymer variable, because plugins requires it.
import '../scripts/bundled-polymer';
@@ -38,10 +37,24 @@
import './gr-app-element';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-app_html';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
import {customElement} from '@polymer/decorators';
import {installPolymerResin} from '../scripts/polymer-resin-install';
+import {createAppContext} from '../services/app-context-init';
+import {
+ initVisibilityReporter,
+ initPerformanceReporter,
+ initErrorReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {injectAppContext} from '../services/app-context';
+
+const appContext = createAppContext();
+injectAppContext(appContext);
+const reportingService = appContext.reportingService;
+initVisibilityReporter(reportingService);
+initPerformanceReporter(reportingService);
+initErrorReporter(reportingService);
+
installPolymerResin(safeTypesBridge);
@customElement('gr-app')
@@ -57,5 +70,4 @@
}
}
-initGlobalVariables();
-initGerritPluginApi();
+initGlobalVariables(appContext);
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
new file mode 100644
index 0000000..50a2782
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+import {Binding} from '../../utils/dom-util';
+import {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {getAppContext} from '../../services/app-context';
+
+interface ShortcutListener {
+ binding: Binding;
+ listener: (e: KeyboardEvent) => void;
+}
+
+type Cleanup = () => void;
+
+export class ShortcutController implements ReactiveController {
+ private readonly service: ShortcutsService = getAppContext().shortcutsService;
+
+ private readonly listenersLocal: ShortcutListener[] = [];
+
+ private readonly listenersGlobal: ShortcutListener[] = [];
+
+ private cleanups: Cleanup[] = [];
+
+ constructor(private readonly host: ReactiveControllerHost & HTMLElement) {
+ host.addController(this);
+ }
+
+ // Note that local shortcuts are *not* suppressed when the user has shortcuts
+ // disabled or when the event comes from elements like <input>. So this method
+ // is intended for shortcuts like ESC and Ctrl-ENTER.
+ // If you need suppressed local shortcuts, then just add an options parameter.
+ addLocal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+ this.listenersLocal.push({binding, listener});
+ }
+
+ addGlobal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+ this.listenersGlobal.push({binding, listener});
+ }
+
+ hostConnected() {
+ for (const {binding, listener} of this.listenersLocal) {
+ const cleanup = this.service.addShortcut(this.host, binding, listener, {
+ shouldSuppress: false,
+ });
+ this.cleanups.push(cleanup);
+ }
+ for (const {binding, listener} of this.listenersGlobal) {
+ const cleanup = this.service.addShortcut(
+ document.body,
+ binding,
+ listener
+ );
+ this.cleanups.push(cleanup);
+ }
+ }
+
+ hostDisconnected() {
+ for (const cleanup of this.cleanups) {
+ cleanup();
+ }
+ this.cleanups = [];
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
index 9a8f75e..6fd2505 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
@@ -18,16 +18,13 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-admin-api tests', () => {
let adminApi;
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
getPluginLoader().loadPlugins([]);
adminApi = plugin.admin();
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index 2d83012..94eb292 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
Polymer({
is: 'gr-attribute-helper-some-element',
@@ -31,15 +30,13 @@
const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-attribute-helper tests', () => {
let element;
let instance;
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
element = basicFixture.instantiate();
instance = plugin.attributeHelper(element);
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 6484f92..e1f3d3c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -43,7 +43,7 @@
export class GrChecksApi implements ChecksPluginApi {
private state = State.NOT_REGISTERED;
- private readonly checksService = getAppContext().checksService;
+ private readonly checksModel = getAppContext().checksModel;
private readonly reporting = getAppContext().reportingService;
@@ -53,14 +53,14 @@
announceUpdate() {
this.reporting.trackApi(this.plugin, 'checks', 'announceUpdate');
- this.checksService.reload(this.plugin.getPluginName());
+ this.checksModel.reload(this.plugin.getPluginName());
}
updateResult(run: CheckRun, result: CheckResult) {
if (result.externalId === undefined) {
throw new Error('ChecksApi.updateResult() was called without externalId');
}
- this.checksService.updateResult(this.plugin.getPluginName(), run, result);
+ this.checksModel.updateResult(this.plugin.getPluginName(), run, result);
}
register(provider: ChecksProvider, config?: ChecksApiConfig): void {
@@ -68,7 +68,7 @@
if (this.state === State.REGISTERED)
throw new Error('Only one provider can be registered per plugin.');
this.state = State.REGISTERED;
- this.checksService.register(
+ this.checksModel.register(
this.plugin.getPluginName(),
provider,
config ?? DEFAULT_CONFIG
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
index e1ec158..596c54b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -17,18 +17,15 @@
import '../../../test/common-test-setup-karma';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
import {PluginApi} from '../../../api/plugin';
import {ChecksPluginApi} from '../../../api/checks';
-const gerritPluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-settings-api tests', () => {
let checksApi: ChecksPluginApi | undefined;
setup(() => {
let pluginApi: PluginApi | undefined = undefined;
- gerritPluginApi.install(
+ window.Gerrit.install(
p => {
pluginApi = p;
},
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
index 883f2a6..025f2b4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -18,9 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-dom-hooks tests', () => {
let instance;
@@ -28,7 +25,7 @@
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
instance = new GrDomHooksManager(plugin);
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
index 1be5e82..893f0d1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -21,9 +21,6 @@
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {resetPlugins} from '../../../test/test-utils.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
const basicFixture = fixtureFromTemplate(
html`<div>
@@ -54,7 +51,9 @@
setup(async () => {
resetPlugins();
container = basicFixture.instantiate();
- pluginApi.install(p => plugin = p, '0.1',
+ window.Gerrit.install(
+ p => { plugin = p; },
+ '0.1',
'http://some/plugin/url.js');
// Decoration
decorationHook = plugin.registerCustomComponent('first', 'some-module');
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
index 4e3d657..13bd535 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -18,7 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {mockPromise} from '../../../test/test-utils.js';
Polymer({
@@ -34,15 +33,13 @@
const basicFixture = fixtureFromElement('gr-event-helper-some-element');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-event-helper tests', () => {
let element;
let instance;
setup(() => {
let plugin;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
element = basicFixture.instantiate();
instance = plugin.eventHelper(element);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
index a192f80..faf7525 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -18,11 +18,8 @@
import {resetPlugins} from '../../../test/test-utils.js';
import './gr-external-style.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
const basicFixture = fixtureFromTemplate(
html`<gr-external-style name="foo"></gr-external-style>`
);
@@ -35,7 +32,7 @@
const installPlugin = () => {
if (plugin) { return; }
- pluginApi.install(p => {
+ window.Gerrit.install(p => {
plugin = p;
}, '0.1', TEST_URL);
};
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
index 2889333..beedfab 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -18,7 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import {GrPopupInterface} from './gr-popup-interface.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
@@ -34,14 +33,13 @@
const containerFixture = fixtureFromElement('div');
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-popup-interface tests', () => {
let container;
let instance;
let plugin;
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
container = containerFixture.instantiate();
sinon.stub(plugin, 'hook').returns({
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 9b11bc6..de8a02d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -19,265 +19,636 @@
import '../gr-comment/gr-comment';
import '../../diff/gr-diff/gr-diff';
import '../gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-thread_html';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, queryAll, state} from 'lit/decorators';
import {
computeDiffFromContext,
- computeId,
- DraftInfo,
isDraft,
isRobot,
- sortComments,
- UIComment,
- UIDraft,
- UIRobot,
+ Comment,
+ CommentThread,
+ getLastComment,
+ UnsavedInfo,
+ isDraftOrUnsaved,
+ createUnsavedComment,
+ getFirstComment,
+ createUnsavedReply,
+ isUnsaved,
} from '../../../utils/comment-util';
+import {ChangeMessageId} from '../../../api/rest-api';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getAppContext} from '../../../services/app-context';
import {
- CommentSide,
createDefaultDiffPrefs,
- Side,
SpecialFilePath,
} from '../../../constants/constants';
import {computeDisplayPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
import {
AccountDetailInfo,
CommentRange,
- ConfigInfo,
NumericChangeId,
- PatchSetNum,
RepoName,
UrlEncodedCommentId,
} from '../../../types/common';
import {GrComment} from '../gr-comment/gr-comment';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
+import {FILE} from '../../diff/gr-diff/gr-diff-line';
import {GrButton} from '../gr-button/gr-button';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {
- assertIsDefined,
- check,
- queryAndAssert,
-} from '../../../utils/common-util';
-import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire, fireAlert, waitForEventOnce} from '../../../utils/event-util';
import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
-import {StorageLocation} from '../../../services/storage/gr-storage';
import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
import {getUserName} from '../../../utils/display-name-util';
import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addGlobalShortcut} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {repeat} from 'lit/directives/repeat';
+import {classMap} from 'lit/directives/class-map';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
-const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
-export interface GrCommentThread {
- $: {
- replyBtn: GrButton;
- quoteBtn: GrButton;
- };
+declare global {
+ interface HTMLElementEventMap {
+ 'comment-thread-editing-changed': ValueChangedEvent<boolean>;
+ }
}
+/**
+ * gr-comment-thread exposes the following attributes that allow a
+ * diff widget like gr-diff to show the thread in the right location:
+ *
+ * line-num:
+ * 1-based line number or 'FILE' if it refers to the entire file.
+ *
+ * diff-side:
+ * "left" or "right". These indicate which of the two diffed versions
+ * the comment relates to. In the case of unified diff, the left
+ * version is the one whose line number column is further to the left.
+ *
+ * range:
+ * The range of text that the comment refers to (start_line,
+ * start_character, end_line, end_character), serialized as JSON. If
+ * set, range's end_line will have the same value as line-num. Line
+ * numbers are 1-based, char numbers are 0-based. The start position
+ * (start_line, start_character) is inclusive, and the end position
+ * (end_line, end_character) is exclusive.
+ */
@customElement('gr-comment-thread')
-export class GrCommentThread extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
+export class GrCommentThread extends LitElement {
+ @query('#replyBtn')
+ replyBtn?: GrButton;
+
+ @query('#quoteBtn')
+ quoteBtn?: GrButton;
+
+ @query('.comment-box')
+ commentBox?: HTMLElement;
+
+ @queryAll('gr-comment')
+ commentElements?: NodeList;
+
+ /** Required to be set by parent. */
+ @property()
+ thread?: CommentThread;
/**
- * gr-comment-thread exposes the following attributes that allow a
- * diff widget like gr-diff to show the thread in the right location:
+ * Id of the first comment and thus must not change. Will be derived from
+ * the `thread` property in the first willUpdate() cycle.
*
- * line-num:
- * 1-based line number or 'FILE' if it refers to the entire file.
+ * The `rootId` property is also used in gr-diff for maintaining lists and
+ * maps of threads and their associated elements.
*
- * diff-side:
- * "left" or "right". These indicate which of the two diffed versions
- * the comment relates to. In the case of unified diff, the left
- * version is the one whose line number column is further to the left.
- *
- * range:
- * The range of text that the comment refers to (start_line,
- * start_character, end_line, end_character), serialized as JSON. If
- * set, range's end_line will have the same value as line-num. Line
- * numbers are 1-based, char numbers are 0-based. The start position
- * (start_line, start_character) is inclusive, and the end position
- * (end_line, end_character) is exclusive.
+ * Only stays `undefined` for new threads that only have an unsaved comment.
*/
- @property({type: Number})
- changeNum?: NumericChangeId;
-
- @property({type: Array})
- comments: UIComment[] = [];
-
- @property({type: Object, reflectToAttribute: true})
- range?: CommentRange;
-
- @property({type: String, reflectToAttribute: true})
- diffSide?: Side;
-
@property({type: String})
- patchNum?: PatchSetNum;
-
- @property({type: String})
- path: string | undefined;
-
- @property({type: String, observer: '_projectNameChanged'})
- projectName?: RepoName;
-
- @property({type: Boolean, notify: true, reflectToAttribute: true})
- hasDraft?: boolean;
-
- @property({type: Boolean})
- isOnParent = false;
-
- @property({type: Number})
- parentIndex: number | null = null;
-
- @property({
- type: String,
- notify: true,
- computed: '_computeRootId(comments.*)',
- })
rootId?: UrlEncodedCommentId;
- @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+ // TODO: Is this attribute needed for querySelector() or css rules?
+ // We don't need this internally for the component.
+ @property({type: Boolean, reflect: true, attribute: 'has-draft'})
+ hasDraft?: boolean;
+
+ /** Will be inspected on firstUpdated() only. */
+ @property({type: Boolean, attribute: 'should-scroll-into-view'})
shouldScrollIntoView = false;
- @property({type: Boolean})
+ /**
+ * Should the file path and line number be rendered above the comment thread
+ * widget? Typically true in <gr-thread-list> and false in <gr-diff>.
+ */
+ @property({type: Boolean, attribute: 'show-file-path'})
showFilePath = false;
- @property({type: Object, reflectToAttribute: true})
- lineNum?: LineNumber;
+ /**
+ * Only relevant when `showFilePath` is set.
+ * If false, then only the line number is rendered.
+ */
+ @property({type: Boolean, attribute: 'show-file-name'})
+ showFileName = false;
- @property({type: Boolean, notify: true, reflectToAttribute: true})
- unresolved?: boolean;
+ @property({type: Boolean, attribute: 'show-ported-comment'})
+ showPortedComment = false;
- @property({type: Boolean})
- _showActions?: boolean;
+ /** This is set to false by <gr-diff>. */
+ @property({type: Boolean, attribute: false})
+ showPatchset = true;
- @property({type: Object})
- _lastComment?: UIComment;
+ @property({type: Boolean, attribute: 'show-comment-context'})
+ showCommentContext = false;
- @property({type: Array})
- _orderedComments: UIComment[] = [];
+ /**
+ * Optional context information when a thread is being displayed for a
+ * specific change message. That influences which comments are expanded or
+ * collapsed by default.
+ */
+ @property({type: String, attribute: 'message-id'})
+ messageId?: ChangeMessageId;
- @property({type: Object})
- _projectConfig?: ConfigInfo;
+ /**
+ * We are reflecting the editing state of the draft comment here. This is not
+ * an input property, but can be inspected from the parent component.
+ *
+ * Changes to this property are fired as 'comment-thread-editing-changed'
+ * events.
+ */
+ @property({type: Boolean, attribute: 'false'})
+ editing = false;
- @property({type: Object})
- _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+ /**
+ * This can either be an unsaved reply to the last comment or the unsaved
+ * content of a brand new comment thread (then `comments` is empty).
+ * If set, then `thread.comments` must not contain a draft. A thread can only
+ * contain *either* an unsaved comment *or* a draft, not both.
+ */
+ @state()
+ unsavedComment?: UnsavedInfo;
- @property({type: Object})
- _renderPrefs: RenderPreferences = {
+ @state()
+ changeNum?: NumericChangeId;
+
+ @state()
+ prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+ @state()
+ renderPrefs: RenderPreferences = {
hide_left_side: true,
disable_context_control_buttons: true,
show_file_comment_button: false,
hide_line_length_indicator: true,
};
- @property({type: Boolean, reflectToAttribute: true})
- isRobotComment = false;
+ @state()
+ repoName?: RepoName;
- @property({type: Boolean})
- showFileName = true;
+ @state()
+ account?: AccountDetailInfo;
- @property({type: Boolean})
- showPortedComment = false;
-
- @property({type: Boolean})
- showPatchset = true;
-
- @property({type: Boolean})
- showCommentContext = false;
-
- @property({type: Object})
- _selfAccount?: AccountDetailInfo;
-
- @property({type: Array})
+ @state()
layers: DiffLayer[] = [];
- @property({type: Object, computed: 'computeDiff(comments, path)'})
- _diff?: DiffInfo;
+ /** Computed during willUpdate(). */
+ @state()
+ diff?: DiffInfo;
- /** Called in disconnectedCallback. */
- private cleanups: (() => void)[] = [];
+ /** Computed during willUpdate(). */
+ @state()
+ highlightRange?: CommentRange;
- private readonly reporting = getAppContext().reportingService;
+ /**
+ * Reflects the *dirty* state of whether the thread is currently unresolved.
+ * We are listening on the <gr-comment> of the draft, so we even know when the
+ * checkbox is checked, even if not yet saved.
+ */
+ @state()
+ unresolved = true;
- private readonly commentsService = getAppContext().commentsService;
+ /**
+ * Normally drafts are saved within the <gr-comment> child component and we
+ * don't care about that. But when creating 'Done.' replies we are actually
+ * saving from this component. True while the REST API call is inflight.
+ */
+ @state()
+ saving = false;
- private readonly restApiService = getAppContext().restApiService;
+ private readonly commentsModel = getAppContext().commentsModel;
- private readonly shortcuts = getAppContext().shortcutsService;
+ private readonly changeModel = getAppContext().changeModel;
- readonly storage = getAppContext().storageService;
+ private readonly userModel = getAppContext().userModel;
+
+ private readonly shortcuts = new ShortcutController(this);
private readonly syntaxLayer = new GrSyntaxLayer();
constructor() {
super();
- this.addEventListener('comment-update', e =>
- this._handleCommentUpdate(e as CustomEvent)
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+ subscribe(this, this.userModel.account$, x => (this.account = x));
+ subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+ subscribe(this, this.userModel.diffPreferences$, x =>
+ this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
);
- this.restApiService.getPreferences().then(prefs => {
- this._initLayers(!!prefs?.disable_token_highlighting);
+ subscribe(this, this.userModel.preferences$, prefs => {
+ const layers: DiffLayer[] = [this.syntaxLayer];
+ if (!prefs.disable_token_highlighting) {
+ layers.push(new TokenHighlightLayer(this));
+ }
+ this.layers = layers;
});
- }
-
- override disconnectedCallback() {
- super.disconnectedCallback();
- for (const cleanup of this.cleanups) cleanup();
- this.cleanups = [];
- }
-
- override connectedCallback() {
- super.connectedCallback();
- this.cleanups.push(
- addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e), {
- doNotPrevent: true,
- shouldSuppress: true,
- })
- );
- this.cleanups.push(
- addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e), {
- doNotPrevent: true,
- shouldSuppress: true,
- })
- );
- this._getLoggedIn().then(loggedIn => {
- this._showActions = loggedIn;
- });
- this.restApiService.getDiffPreferences().then(prefs => {
- if (!prefs) return;
- this._prefs = {
+ subscribe(this, this.userModel.diffPreferences$, prefs => {
+ this.prefs = {
...prefs,
// set line_wrapping to true so that the context can take all the
// remaining space after comment card has rendered
line_wrapping: true,
};
- this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
});
- this.restApiService.getAccount().then(account => {
- this._selfAccount = account;
- });
- this._setInitialExpandedState();
+ this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
+ this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
}
- computeDiff(comments?: UIComment[], path?: string) {
- if (comments === undefined || path === undefined) return undefined;
- if (!comments[0]?.context_lines?.length) return undefined;
+ static override get styles() {
+ return [
+ a11yStyles,
+ sharedStyles,
+ css`
+ :host {
+ font-family: var(--font-family);
+ font-size: var(--font-size-normal);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--line-height-normal);
+ /* Explicitly set the background color of the diff. We
+ * cannot use the diff content type ab because of the skip chunk preceding
+ * it, diff processor assumes the chunk of type skip/ab can be collapsed
+ * and hides our diff behind context control buttons.
+ * */
+ --dark-add-highlight-color: var(--background-color-primary);
+ }
+ gr-button {
+ margin-left: var(--spacing-m);
+ }
+ gr-comment {
+ border-bottom: 1px solid var(--comment-separator-color);
+ }
+ #actions {
+ margin-left: auto;
+ padding: var(--spacing-s) var(--spacing-m);
+ }
+ .comment-box {
+ width: 80ch;
+ max-width: 100%;
+ background-color: var(--comment-background-color);
+ color: var(--comment-text-color);
+ box-shadow: var(--elevation-level-2);
+ border-radius: var(--border-radius);
+ flex-shrink: 0;
+ }
+ #container {
+ display: var(--gr-comment-thread-display, flex);
+ align-items: flex-start;
+ margin: 0 var(--spacing-s) var(--spacing-s);
+ white-space: normal;
+ /** This is required for firefox to continue the inheritance */
+ -webkit-user-select: inherit;
+ -moz-user-select: inherit;
+ -ms-user-select: inherit;
+ user-select: inherit;
+ }
+ .comment-box.unresolved {
+ background-color: var(--unresolved-comment-background-color);
+ }
+ .comment-box.robotComment {
+ background-color: var(--robot-comment-background-color);
+ }
+ #actionsContainer {
+ display: flex;
+ }
+ .comment-box.saving #actionsContainer {
+ opacity: 0.5;
+ }
+ #unresolvedLabel {
+ font-family: var(--font-family);
+ margin: auto 0;
+ padding: var(--spacing-m);
+ }
+ .pathInfo {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 0 var(--spacing-s) var(--spacing-s);
+ }
+ .fileName {
+ padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+ }
+ @media only screen and (max-width: 1200px) {
+ .diff-container {
+ display: none;
+ }
+ }
+ .diff-container {
+ margin-left: var(--spacing-l);
+ border: 1px solid var(--border-color);
+ flex-grow: 1;
+ flex-shrink: 1;
+ max-width: 1200px;
+ }
+ .view-diff-button {
+ margin: var(--spacing-s) var(--spacing-m);
+ }
+ .view-diff-container {
+ border-top: 1px solid var(--border-color);
+ background-color: var(--background-color-primary);
+ }
+
+ /* In saved state the "reply" and "quote" buttons are 28px height.
+ * top:4px positions the 20px icon vertically centered.
+ * Currently in draft state the "save" and "cancel" buttons are 20px
+ * height, so the link icon does not need a top:4px in gr-comment_html.
+ */
+ .link-icon {
+ position: relative;
+ top: 4px;
+ cursor: pointer;
+ }
+ .fileName gr-copy-clipboard {
+ display: inline-block;
+ visibility: hidden;
+ vertical-align: top;
+ --gr-button-padding: 0px;
+ }
+ .fileName:focus-within gr-copy-clipboard,
+ .fileName:hover gr-copy-clipboard {
+ visibility: visible;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (!this.thread) return;
+ const dynamicBoxClasses = {
+ robotComment: this.isRobotComment(),
+ unresolved: this.unresolved,
+ saving: this.saving,
+ };
+ return html`
+ ${this.renderFilePath()}
+ <div id="container">
+ <h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
+ <div class="comment-box ${classMap(dynamicBoxClasses)}" tabindex="0">
+ ${this.renderComments()} ${this.renderActions()}
+ </div>
+ ${this.renderContextualDiff()}
+ </div>
+ `;
+ }
+
+ renderFilePath() {
+ if (!this.showFilePath) return;
+ const href = this.getUrlForComment();
+ const line = this.computeDisplayLine();
+ return html`
+ ${this.renderFileName()}
+ <div class="pathInfo">
+ ${href
+ ? html`<a href="${href}">${line}</a>`
+ : html`<span>${line}</span>`}
+ </div>
+ `;
+ }
+
+ renderFileName() {
+ if (!this.showFileName) return;
+ if (this.isPatchsetLevel()) {
+ return html`<div class="fileName"><span>Patchset</span></div>`;
+ }
+ const href = this.getDiffUrlForPath();
+ const displayPath = this.getDisplayPath();
+ return html`
+ <div class="fileName">
+ ${href
+ ? html`<a href="${href}">${displayPath}</a>`
+ : html`<span>${displayPath}</span>`}
+ <gr-copy-clipboard hideInput .text="${displayPath}"></gr-copy-clipboard>
+ </div>
+ `;
+ }
+
+ renderComments() {
+ assertIsDefined(this.thread, 'thread');
+ const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+ const comments: Comment[] = [...this.thread.comments];
+ if (this.unsavedComment && !this.isDraft()) {
+ comments.push(this.unsavedComment);
+ }
+ return repeat(
+ comments,
+ // We want to reuse <gr-comment> when unsaved changes to draft.
+ comment => (isDraftOrUnsaved(comment) ? 'unsaved' : comment.id),
+ comment => {
+ const initiallyCollapsed =
+ !isDraftOrUnsaved(comment) &&
+ (this.messageId
+ ? comment.change_message_id !== this.messageId
+ : !this.unresolved);
+ return html`
+ <gr-comment
+ .comment="${comment}"
+ .comments="${this.thread!.comments}"
+ .patchNum="${this.thread?.patchNum}"
+ ?initially-collapsed="${initiallyCollapsed}"
+ ?robot-button-disabled="${robotButtonDisabled}"
+ ?show-patchset="${this.showPatchset}"
+ ?show-ported-comment="${this.showPortedComment &&
+ comment.id === this.rootId}"
+ @create-fix-comment="${this.handleCommentFix}"
+ @copy-comment-link="${this.handleCopyLink}"
+ @comment-editing-changed="${(e: CustomEvent) => {
+ if (isDraftOrUnsaved(comment)) this.editing = e.detail;
+ }}"
+ @comment-unresolved-changed="${(e: CustomEvent) => {
+ if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
+ }}"
+ ></gr-comment>
+ `;
+ }
+ );
+ }
+
+ renderActions() {
+ if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
+ return;
+ return html`
+ <div id="actionsContainer">
+ <span id="unresolvedLabel">${
+ this.unresolved ? 'Unresolved' : 'Resolved'
+ }</span>
+ <div id="actions">
+ <iron-icon
+ class="link-icon copy"
+ @click="${this.handleCopyLink}"
+ title="Copy link to this comment"
+ icon="gr-icons:link"
+ role="button"
+ tabindex="0"
+ >
+ </iron-icon>
+ <gr-button
+ id="replyBtn"
+ link
+ class="action reply"
+ ?disabled="${this.saving}"
+ @click="${() => this.handleCommentReply(false)}"
+ >Reply</gr-button
+ >
+ <gr-button
+ id="quoteBtn"
+ link
+ class="action quote"
+ ?disabled="${this.saving}"
+ @click="${() => this.handleCommentReply(true)}"
+ >Quote</gr-button
+ >
+ ${
+ this.unresolved
+ ? html`
+ <gr-button
+ id="ackBtn"
+ link
+ class="action ack"
+ ?disabled="${this.saving}"
+ @click="${this.handleCommentAck}"
+ >Ack</gr-button
+ >
+ <gr-button
+ id="doneBtn"
+ link
+ class="action done"
+ ?disabled="${this.saving}"
+ @click="${this.handleCommentDone}"
+ >Done</gr-button
+ >
+ `
+ : ''
+ }
+ </div>
+ </div>
+ </div>
+ `;
+ }
+
+ renderContextualDiff() {
+ if (!this.changeNum || !this.showCommentContext || !this.diff) return;
+ if (!this.thread?.path) return;
+ const href = this.getUrlForComment();
+ return html`
+ <div class="diff-container">
+ <gr-diff
+ id="diff"
+ .changeNum="${this.changeNum}"
+ .diff="${this.diff}"
+ .layers="${this.layers}"
+ .path="${this.thread.path}"
+ .prefs="${this.prefs}"
+ .renderPrefs="${this.renderPrefs}"
+ .highlightRange="${this.highlightRange}"
+ >
+ </gr-diff>
+ <div class="view-diff-container">
+ <a href="${href}">
+ <gr-button link class="view-diff-button">View Diff</gr-button>
+ </a>
+ </div>
+ </div>
+ `;
+ }
+
+ private firstWillUpdateDone = false;
+
+ firstWillUpdate() {
+ if (!this.thread) return;
+ if (this.firstWillUpdateDone) return;
+ this.firstWillUpdateDone = true;
+
+ if (this.getFirstComment() === undefined) {
+ this.unsavedComment = createUnsavedComment(this.thread);
+ }
+ this.unresolved = this.getLastComment()?.unresolved ?? true;
+ this.diff = this.computeDiff();
+ this.highlightRange = this.computeHighlightRange();
+ }
+
+ override willUpdate(changed: PropertyValues) {
+ this.firstWillUpdate();
+ if (changed.has('thread')) {
+ if (!this.isDraftOrUnsaved()) {
+ // We can only do this for threads without draft, because otherwise we
+ // are relying on the <gr-comment> component for the draft to fire
+ // events about the *dirty* `unresolved` state.
+ this.unresolved = this.getLastComment()?.unresolved ?? true;
+ }
+ this.hasDraft = this.isDraftOrUnsaved();
+ this.rootId = this.getFirstComment()?.id;
+ if (this.isDraft()) {
+ this.unsavedComment = undefined;
+ }
+ }
+ if (changed.has('editing')) {
+ if (!this.editing) {
+ this.unsavedComment = undefined;
+ if (this.thread?.comments.length === 0) {
+ this.remove();
+ }
+ }
+ fire(this, 'comment-thread-editing-changed', {value: this.editing});
+ }
+ }
+
+ override firstUpdated() {
+ if (this.shouldScrollIntoView) {
+ this.commentBox?.focus();
+ this.scrollIntoView();
+ }
+ }
+
+ private isDraft() {
+ return isDraft(this.getLastComment());
+ }
+
+ private isDraftOrUnsaved(): boolean {
+ return this.isDraft() || this.isUnsaved();
+ }
+
+ private isNewThread(): boolean {
+ return this.thread?.comments.length === 0;
+ }
+
+ private isUnsaved(): boolean {
+ return !!this.unsavedComment || this.thread?.comments.length === 0;
+ }
+
+ private isPatchsetLevel() {
+ return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+ }
+
+ private computeDiff() {
+ if (!this.showCommentContext) return;
+ if (!this.thread?.path) return;
+ const firstComment = this.getFirstComment();
+ if (!firstComment?.context_lines?.length) return;
const diff = computeDiffFromContext(
- comments[0].context_lines,
- path,
- comments[0].source_content_type
+ firstComment.context_lines,
+ this.thread?.path,
+ firstComment.source_content_type
);
// Do we really have to re-compute (and re-render) the diff?
- if (this._diff && JSON.stringify(this._diff) === JSON.stringify(diff)) {
- return this._diff;
+ if (this.diff && JSON.stringify(this.diff) === JSON.stringify(diff)) {
+ return this.diff;
}
if (!anyLineTooLong(diff)) {
@@ -289,83 +660,21 @@
return diff;
}
- handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
- // Wait for comment to be rendered before scrolling to it
- if (shouldScrollIntoView) {
- const resizeObserver = new ResizeObserver(
- (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
- if (this.offsetHeight > 0) {
- queryAndAssert<HTMLDivElement>(this, '.comment-box').focus();
- this.scrollIntoView();
- }
- observer.unobserve(this);
- }
- );
- resizeObserver.observe(this);
+ private getDiffUrlForPath() {
+ if (!this.changeNum || !this.repoName || !this.thread?.path) {
+ return undefined;
}
+ if (this.isNewThread()) return undefined;
+ return GerritNav.getUrlForDiffById(
+ this.changeNum,
+ this.repoName,
+ this.thread.path,
+ this.thread.patchNum
+ );
}
- _shouldShowCommentContext(
- changeNum?: NumericChangeId,
- showCommentContext?: boolean,
- diff?: DiffInfo
- ) {
- return changeNum && showCommentContext && !!diff;
- }
-
- addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
- const lastComment = this.comments[this.comments.length - 1] || {};
- if (isDraft(lastComment)) {
- const commentEl = this._commentElWithDraftID(
- lastComment.id || lastComment.__draftID
- );
- if (!commentEl) throw new Error('Failed to find draft.');
- commentEl.editing = true;
-
- // If the comment was collapsed, re-open it to make it clear which
- // actions are available.
- commentEl.collapsed = false;
- } else {
- const range = rangeParam
- ? rangeParam
- : lastComment
- ? lastComment.range
- : undefined;
- const unresolved = lastComment ? lastComment.unresolved : undefined;
- this.addDraft(lineNum, range, unresolved);
- }
- }
-
- addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
- const draft = this._newDraft(lineNum, range);
- draft.__editing = true;
- draft.unresolved = unresolved === false ? unresolved : true;
- this.commentsService.addDraft(draft);
- }
-
- _getDiffUrlForPath(
- projectName?: RepoName,
- changeNum?: NumericChangeId,
- path?: string,
- patchNum?: PatchSetNum
- ) {
- if (!changeNum || !projectName || !path) return undefined;
- if (isDraft(this.comments[0])) {
- return GerritNav.getUrlForDiffById(
- changeNum,
- projectName,
- path,
- patchNum
- );
- }
- const id = this.comments[0].id;
- if (!id) throw new Error('A published comment is missing the id.');
- return GerritNav.getUrlForComment(changeNum, projectName, id);
- }
-
- /** The parameter is for triggering re-computation only. */
- getHighlightRange(_: unknown) {
- const comment = this.comments?.[0];
+ private computeHighlightRange() {
+ const comment = this.getFirstComment();
if (!comment) return undefined;
if (comment.range) return comment.range;
if (comment.line) {
@@ -379,413 +688,130 @@
return undefined;
}
- _initLayers(disableTokenHighlighting: boolean) {
- if (!disableTokenHighlighting) {
- this.layers.push(new TokenHighlightLayer(this));
+ private getUrlForComment() {
+ if (!this.repoName || !this.changeNum || this.isNewThread()) {
+ return undefined;
}
- this.layers.push(this.syntaxLayer);
- }
-
- _getUrlForViewDiff(
- comments: UIComment[],
- changeNum?: NumericChangeId,
- projectName?: RepoName
- ): string {
- if (!changeNum) return '';
- if (!projectName) return '';
- check(comments.length > 0, 'comment not found');
- return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
- }
-
- _getDiffUrlForComment(
- projectName?: RepoName,
- changeNum?: NumericChangeId,
- path?: string,
- patchNum?: PatchSetNum
- ) {
- if (!projectName || !changeNum || !path) return undefined;
- if (
- (this.comments.length && this.comments[0].side === 'PARENT') ||
- isDraft(this.comments[0])
- ) {
- if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
- return GerritNav.getUrlForDiffById(
- changeNum,
- projectName,
- path,
- patchNum,
- undefined,
- this.lineNum === FILE ? undefined : this.lineNum
- );
- }
- const id = this.comments[0].id;
- if (!id) throw new Error('A published comment is missing the id.');
- return GerritNav.getUrlForComment(changeNum, projectName, id);
- }
-
- handleCopyLink() {
- assertIsDefined(this.changeNum, 'changeNum');
- assertIsDefined(this.projectName, 'projectName');
- const url = generateAbsoluteUrl(
- GerritNav.getUrlForCommentsTab(
- this.changeNum,
- this.projectName,
- this.comments[0].id!
- )
+ assertIsDefined(this.rootId, 'rootId of comment thread');
+ return GerritNav.getUrlForComment(
+ this.changeNum,
+ this.repoName,
+ this.rootId
);
- navigator.clipboard.writeText(url).then(() => {
+ }
+
+ private handleCopyLink() {
+ const url = this.getUrlForComment();
+ assertIsDefined(url, 'url for comment');
+ navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
fireAlert(this, 'Link copied to clipboard');
});
}
- _isPatchsetLevelComment(path?: string) {
- return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+ private getDisplayPath() {
+ if (this.isPatchsetLevel()) return 'Patchset';
+ return computeDisplayPath(this.thread?.path);
}
- _computeShowPortedComment(comment: UIComment) {
- if (this._orderedComments.length === 0) return false;
- return this.showPortedComment && comment.id === this._orderedComments[0].id;
- }
-
- _computeDisplayPath(path?: string) {
- const displayPath = computeDisplayPath(path);
- if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return 'Patchset';
- }
- return displayPath;
- }
-
- _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
- if (lineNum === FILE) {
- if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return '';
- }
- return FILE;
- }
- if (lineNum) return `#${lineNum}`;
+ private computeDisplayLine() {
+ assertIsDefined(this.thread, 'thread');
+ if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE;
+ if (this.thread.line) return `#${this.thread.line}`;
// If range is set, then lineNum equals the end line of the range.
- if (range) return `#${range.end_line}`;
+ if (this.thread.range) return `#${this.thread.range.end_line}`;
return '';
}
- _getLoggedIn() {
- return this.restApiService.getLoggedIn();
+ private isRobotComment() {
+ return isRobot(this.getLastComment());
}
- _getUnresolvedLabel(unresolved?: boolean) {
- return unresolved ? 'Unresolved' : 'Resolved';
+ private getFirstComment() {
+ assertIsDefined(this.thread);
+ return getFirstComment(this.thread);
}
- @observe('comments.*')
- _commentsChanged() {
- this._orderedComments = sortComments(this.comments);
- this.updateThreadProperties();
+ private getLastComment() {
+ assertIsDefined(this.thread);
+ return getLastComment(this.thread);
}
- updateThreadProperties() {
- if (this._orderedComments.length) {
- this._lastComment = this._getLastComment();
- this.unresolved = this._lastComment.unresolved;
- this.hasDraft = isDraft(this._lastComment);
- this.isRobotComment = isRobot(this._lastComment);
+ private handleExpandShortcut() {
+ this.expandCollapseComments(false);
+ }
+
+ private handleCollapseShortcut() {
+ this.expandCollapseComments(true);
+ }
+
+ private expandCollapseComments(actionIsCollapse: boolean) {
+ for (const comment of this.commentElements ?? []) {
+ (comment as GrComment).collapsed = actionIsCollapse;
}
}
- _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
- return !_showActions || !_lastComment || isDraft(_lastComment);
- }
-
- _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
- return (
- this._shouldDisableAction(_showActions, _lastComment) ||
- isRobot(_lastComment)
- );
- }
-
- _getLastComment() {
- return this._orderedComments[this._orderedComments.length - 1] || {};
- }
-
- private handleExpandShortcut(e: KeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- this._expandCollapseComments(false);
- }
-
- private handleCollapseShortcut(e: KeyboardEvent) {
- if (this.shortcuts.shouldSuppress(e)) return;
- this._expandCollapseComments(true);
- }
-
- _expandCollapseComments(actionIsCollapse: boolean) {
- const comments = this.root?.querySelectorAll('gr-comment');
- if (!comments) return;
- for (const comment of comments) {
- comment.collapsed = actionIsCollapse;
+ private async createReplyComment(
+ content: string,
+ userWantsToEdit: boolean,
+ unresolved: boolean
+ ) {
+ const replyingTo = this.getLastComment();
+ assertIsDefined(this.thread, 'thread');
+ assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
+ if (isDraft(replyingTo)) {
+ throw new Error('cannot reply to draft');
}
- }
-
- /**
- * Sets the initial state of the comment thread.
- * Expands the thread if one of the following is true:
- * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
- * thread is unresolved,
- * - it's a robot comment.
- * - it's a draft
- */
- _setInitialExpandedState() {
- if (this._orderedComments) {
- for (let i = 0; i < this._orderedComments.length; i++) {
- const comment = this._orderedComments[i];
- if (isDraft(comment)) {
- comment.collapsed = false;
- continue;
- }
- const isRobotComment = !!(comment as UIRobot).robot_id;
- // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
- const resolvedThread =
- !this.unresolved ||
- this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
- if (comment.collapsed === undefined) {
- comment.collapsed = !isRobotComment && resolvedThread;
- }
+ if (isUnsaved(replyingTo)) {
+ throw new Error('cannot reply to unsaved comment');
+ }
+ const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+ if (userWantsToEdit) {
+ this.unsavedComment = unsaved;
+ } else {
+ try {
+ this.saving = true;
+ await this.commentsModel.saveDraft(unsaved);
+ } finally {
+ this.saving = false;
}
}
}
- _createReplyComment(
- content?: string,
- isEditing?: boolean,
- unresolved?: boolean
- ) {
- this.reporting.recordDraftInteraction();
- const id = this._orderedComments[this._orderedComments.length - 1].id;
- if (!id) throw new Error('Cannot reply to comment without id.');
- const reply = this._newReply(id, content, unresolved);
-
- if (isEditing) {
- reply.__editing = true;
- this.commentsService.addDraft(reply);
- } else {
- assertIsDefined(this.changeNum, 'changeNum');
- assertIsDefined(this.patchNum, 'patchNum');
- this.restApiService
- .saveDiffDraft(this.changeNum, this.patchNum, reply)
- .then(result => {
- if (!result.ok) {
- fireAlert(document, 'Unable to restore draft');
- return;
- }
- this.restApiService.getResponseObject(result).then(obj => {
- const resComment = obj as unknown as DraftInfo;
- resComment.patch_set = reply.patch_set;
- this.commentsService.addDraft(resComment);
- });
- });
- }
- }
-
- _isDraft(comment: UIComment) {
- return isDraft(comment);
- }
-
- _processCommentReply(quote?: boolean) {
- const comment = this._lastComment;
+ private handleCommentReply(quote: boolean) {
+ const comment = this.getLastComment();
if (!comment) throw new Error('Failed to find last comment.');
- let content = undefined;
+ let content = '';
if (quote) {
const msg = comment.message;
if (!msg) throw new Error('Quoting empty comment.');
content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
}
- this._createReplyComment(content, true, comment.unresolved);
+ this.createReplyComment(content, true, comment.unresolved ?? true);
}
- _handleCommentReply() {
- this._processCommentReply();
+ private handleCommentAck() {
+ this.createReplyComment('Ack', false, false);
}
- _handleCommentQuote() {
- this._processCommentReply(true);
+ private handleCommentDone() {
+ this.createReplyComment('Done', false, false);
}
- _handleCommentAck() {
- this._createReplyComment('Ack', false, false);
- }
-
- _handleCommentDone() {
- this._createReplyComment('Done', false, false);
- }
-
- _handleCommentFix(e: CustomEvent) {
+ private handleCommentFix(e: CustomEvent) {
const comment = e.detail.comment;
const msg = comment.message;
const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
const quoteStr = '> ' + quoted + '\n\n';
const response = quoteStr + 'Please fix.';
- this._createReplyComment(response, false, true);
+ this.createReplyComment(response, false, true);
}
- _commentElWithDraftID(id?: string): GrComment | null {
- if (!id) return null;
- const els = this.root?.querySelectorAll('gr-comment');
- if (!els) return null;
- for (const el of els) {
- const c = el.comment;
- if (isRobot(c)) continue;
- if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
- }
- return null;
- }
-
- _newReply(
- inReplyTo: UrlEncodedCommentId,
- message?: string,
- unresolved?: boolean
- ) {
- const d = this._newDraft();
- d.in_reply_to = inReplyTo;
- if (message !== undefined) {
- d.message = message;
- }
- if (unresolved !== undefined) {
- d.unresolved = unresolved;
- }
- return d;
- }
-
- _newDraft(lineNum?: LineNumber, range?: CommentRange) {
- const d: UIDraft = {
- __draft: true,
- __draftID: 'draft__' + Math.random().toString(36),
- __date: new Date(),
- };
- if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
- // For replies, always use same meta info as root.
- if (this.comments && this.comments.length >= 1) {
- const rootComment = this.comments[0];
- if (rootComment.path !== undefined) d.path = rootComment.path;
- if (rootComment.patch_set !== undefined)
- d.patch_set = rootComment.patch_set;
- if (rootComment.side !== undefined) d.side = rootComment.side;
- if (rootComment.line !== undefined) d.line = rootComment.line;
- if (rootComment.range !== undefined) d.range = rootComment.range;
- if (rootComment.parent !== undefined) d.parent = rootComment.parent;
- } else {
- // Set meta info for root comment.
- d.path = this.path;
- d.patch_set = this.patchNum;
- d.side = this._getSide(this.isOnParent);
-
- if (lineNum && lineNum !== FILE) {
- d.line = lineNum;
- }
- if (range) {
- d.range = range;
- }
- if (this.parentIndex) {
- d.parent = this.parentIndex;
- }
- }
- return d;
- }
-
- _getSide(isOnParent: boolean): CommentSide {
- return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
- }
-
- _computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
- // Keep the root ID even if the comment was removed, so that notification
- // to sync will know which thread to remove.
- if (!comments.base.length) {
- return this.rootId;
- }
- return computeId(comments.base[0]);
- }
-
- _handleCommentDiscard() {
- assertIsDefined(this.changeNum, 'changeNum');
- assertIsDefined(this.patchNum, 'patchNum');
- // Check to see if there are any other open comments getting edited and
- // set the local storage value to its message value.
- for (const changeComment of this.comments) {
- if (isDraft(changeComment) && changeComment.__editing) {
- const commentLocation: StorageLocation = {
- changeNum: this.changeNum,
- patchNum: this.patchNum,
- path: changeComment.path,
- line: changeComment.line,
- };
- this.storage.setDraftComment(
- commentLocation,
- changeComment.message ?? ''
- );
- }
- }
- }
-
- _handleCommentUpdate(e: CustomEvent) {
- const comment = e.detail.comment;
- const index = this._indexOf(comment, this.comments);
- if (index === -1) {
- // This should never happen: comment belongs to another thread.
- this.reporting.error(
- new Error(`Comment update for another comment thread: ${comment}`)
- );
- return;
- }
- this.set(['comments', index], comment);
- // Because of the way we pass these comment objects around by-ref, in
- // combination with the fact that Polymer does dirty checking in
- // observers, the this.set() call above will not cause a thread update in
- // some situations.
- this.updateThreadProperties();
- }
-
- _indexOf(comment: UIComment | undefined, arr: UIComment[]) {
- if (!comment) return -1;
- for (let i = 0; i < arr.length; i++) {
- const c = arr[i];
- if (
- (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
- (c.id && c.id === comment.id)
- ) {
- return i;
- }
- }
- return -1;
- }
-
- /** 2nd parameter is for triggering re-computation only. */
- _computeHostClass(unresolved?: boolean, _?: unknown) {
- if (this.isRobotComment) {
- return 'robotComment';
- }
- return unresolved ? 'unresolved' : '';
- }
-
- /**
- * Load the project config when a project name has been provided.
- *
- * @param name The project name.
- */
- _projectNameChanged(name?: RepoName) {
- if (!name) {
- return;
- }
- this.restApiService.getProjectConfig(name).then(config => {
- this._projectConfig = config;
- });
- }
-
- _computeAriaHeading(_orderedComments: UIComment[]) {
- const firstComment = _orderedComments[0];
- const author = firstComment?.author ?? this._selfAccount;
- const lastComment = _orderedComments[_orderedComments.length - 1] || {};
- const status = [
- lastComment.unresolved ? 'Unresolved' : '',
- isDraft(lastComment) ? 'Draft' : '',
- ].join(' ');
- return `${status} Comment thread by ${getUserName(undefined, author)}`;
+ private computeAriaHeading() {
+ const author = this.getFirstComment()?.author ?? this.account;
+ const user = getUserName(undefined, author);
+ const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
+ const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+ return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
deleted file mode 100644
index c3faaa5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="gr-a11y-styles">
- /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
- </style>
- <style include="shared-styles">
- :host {
- font-family: var(--font-family);
- font-size: var(--font-size-normal);
- font-weight: var(--font-weight-normal);
- line-height: var(--line-height-normal);
- /* Explicitly set the background color of the diff. We
- * cannot use the diff content type ab because of the skip chunk preceding
- * it, diff processor assumes the chunk of type skip/ab can be collapsed
- * and hides our diff behind context control buttons.
- * */
- --dark-add-highlight-color: var(--background-color-primary);
- }
- gr-button {
- margin-left: var(--spacing-m);
- }
- gr-comment {
- border-bottom: 1px solid var(--comment-separator-color);
- }
- #actions {
- margin-left: auto;
- padding: var(--spacing-s) var(--spacing-m);
- }
- .comment-box {
- width: 80ch;
- max-width: 100%;
- background-color: var(--comment-background-color);
- color: var(--comment-text-color);
- box-shadow: var(--elevation-level-2);
- border-radius: var(--border-radius);
- flex-shrink: 0;
- }
- #container {
- display: var(--gr-comment-thread-display, flex);
- align-items: flex-start;
- margin: 0 var(--spacing-s) var(--spacing-s);
- white-space: normal;
- /** This is required for firefox to continue the inheritance */
- -webkit-user-select: inherit;
- -moz-user-select: inherit;
- -ms-user-select: inherit;
- user-select: inherit;
- }
- .comment-box.unresolved {
- background-color: var(--unresolved-comment-background-color);
- }
- .comment-box.robotComment {
- background-color: var(--robot-comment-background-color);
- }
- #commentInfoContainer {
- display: flex;
- }
- #unresolvedLabel {
- font-family: var(--font-family);
- margin: auto 0;
- padding: var(--spacing-m);
- }
- .pathInfo {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- padding: 0 var(--spacing-s) var(--spacing-s);
- }
- .fileName {
- padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
- }
- @media only screen and (max-width: 1200px) {
- .diff-container {
- display: none;
- }
- }
- .diff-container {
- margin-left: var(--spacing-l);
- border: 1px solid var(--border-color);
- flex-grow: 1;
- flex-shrink: 1;
- max-width: 1200px;
- }
- .view-diff-button {
- margin: var(--spacing-s) var(--spacing-m);
- }
- .view-diff-container {
- border-top: 1px solid var(--border-color);
- background-color: var(--background-color-primary);
- }
-
- /* In saved state the "reply" and "quote" buttons are 28px height.
- * top:4px positions the 20px icon vertically centered.
- * Currently in draft state the "save" and "cancel" buttons are 20px
- * height, so the link icon does not need a top:4px in gr-comment_html.
- */
- .link-icon {
- position: relative;
- top: 4px;
- cursor: pointer;
- }
- .fileName gr-copy-clipboard {
- display: inline-block;
- visibility: hidden;
- vertical-align: top;
- --gr-button-padding: 0px;
- }
- .fileName:focus-within gr-copy-clipboard,
- .fileName:hover gr-copy-clipboard {
- visibility: visible;
- }
- </style>
-
- <template is="dom-if" if="[[showFilePath]]">
- <template is="dom-if" if="[[showFileName]]">
- <div class="fileName">
- <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
- <span> [[_computeDisplayPath(path)]] </span>
- </template>
- <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
- <a
- href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
- >
- [[_computeDisplayPath(path)]]
- </a>
- <gr-copy-clipboard
- hideInput=""
- text="[[_computeDisplayPath(path)]]"
- ></gr-copy-clipboard>
- </template>
- </div>
- </template>
- <div class="pathInfo">
- <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
- <a
- href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
- >[[_computeDisplayLine(lineNum, range)]]</a
- >
- </template>
- </div>
- </template>
- <div id="container">
- <h3 class="assistive-tech-only">
- [[_computeAriaHeading(_orderedComments)]]
- </h3>
- <div
- class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box"
- tabindex="0"
- >
- <template
- id="commentList"
- is="dom-repeat"
- items="[[_orderedComments]]"
- as="comment"
- >
- <gr-comment
- comment="{{comment}}"
- comments="{{comments}}"
- robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
- change-num="[[changeNum]]"
- project-name="[[projectName]]"
- patch-num="[[patchNum]]"
- draft="[[_isDraft(comment)]]"
- show-actions="[[_showActions]]"
- show-patchset="[[showPatchset]]"
- show-ported-comment="[[_computeShowPortedComment(comment)]]"
- side="[[comment.side]]"
- project-config="[[_projectConfig]]"
- on-create-fix-comment="_handleCommentFix"
- on-comment-discard="_handleCommentDiscard"
- on-copy-comment-link="handleCopyLink"
- ></gr-comment>
- </template>
- <div
- id="commentInfoContainer"
- hidden$="[[_hideActions(_showActions, _lastComment)]]"
- >
- <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
- <div id="actions">
- <iron-icon
- class="link-icon"
- on-click="handleCopyLink"
- class="copy"
- title="Copy link to this comment"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- >
- </iron-icon>
- <gr-button
- id="replyBtn"
- link=""
- class="action reply"
- on-click="_handleCommentReply"
- >Reply</gr-button
- >
- <gr-button
- id="quoteBtn"
- link=""
- class="action quote"
- on-click="_handleCommentQuote"
- >Quote</gr-button
- >
- <template is="dom-if" if="[[unresolved]]">
- <gr-button
- id="ackBtn"
- link=""
- class="action ack"
- on-click="_handleCommentAck"
- >Ack</gr-button
- >
- <gr-button
- id="doneBtn"
- link=""
- class="action done"
- on-click="_handleCommentDone"
- >Done</gr-button
- >
- </template>
- </div>
- </div>
- </div>
- <template
- is="dom-if"
- if="[[_shouldShowCommentContext(changeNum, showCommentContext, _diff)]]"
- >
- <div class="diff-container">
- <gr-diff
- id="diff"
- change-num="[[changeNum]]"
- diff="[[_diff]]"
- layers="[[layers]]"
- path="[[path]]"
- prefs="[[_prefs]]"
- render-prefs="[[_renderPrefs]]"
- highlight-range="[[getHighlightRange(comments)]]"
- >
- </gr-diff>
- <div class="view-diff-container">
- <a href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
- <gr-button link class="view-diff-button">View Diff</gr-button>
- </a>
- </div>
- </div>
- </template>
- </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index a4664ee..ab08996 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -14,1005 +14,334 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import '../../../test/common-test-setup-karma';
import './gr-comment-thread';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {SpecialFilePath, Side} from '../../../constants/constants';
-import {
- sortComments,
- UIComment,
- UIRobot,
- UIDraft,
-} from '../../../utils/comment-util';
+import {sortComments} from '../../../utils/comment-util';
import {GrCommentThread} from './gr-comment-thread';
import {
- PatchSetNum,
NumericChangeId,
UrlEncodedCommentId,
Timestamp,
- RobotId,
- RobotRunId,
+ CommentInfo,
RepoName,
- ConfigInfo,
- EmailAddress,
} from '../../../types/common';
-import {GrComment} from '../gr-comment/gr-comment';
-import {LineNumber} from '../../diff/gr-diff/gr-diff-line';
-import {
- tap,
- pressAndReleaseKeyOn,
-} from '@polymer/iron-test-helpers/mock-interactions';
import {
mockPromise,
+ queryAndAssert,
stubComments,
- stubReporting,
stubRestApi,
+ waitUntilCalled,
+ MockPromise,
} from '../../../test/test-utils';
-import {createComment} from '../../../test/test-data-generators';
+import {
+ createAccountDetailWithId,
+ createThread,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
import {SinonStub} from 'sinon';
const basicFixture = fixtureFromElement('gr-comment-thread');
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
+const c1 = {
+ author: {name: 'Kermit'},
+ id: 'the-root' as UrlEncodedCommentId,
+ message: 'start the conversation',
+ updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const c2 = {
+ author: {name: 'Ms Piggy'},
+ id: 'the-reply' as UrlEncodedCommentId,
+ message: 'keep it going',
+ updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+ in_reply_to: 'the-root' as UrlEncodedCommentId,
+};
+
+const c3 = {
+ author: {name: 'Kermit'},
+ id: 'the-draft' as UrlEncodedCommentId,
+ message: 'stop it',
+ updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+ in_reply_to: 'the-reply' as UrlEncodedCommentId,
+ __draft: true,
+};
+
+const commentWithContext = {
+ author: {name: 'Kermit'},
+ id: 'the-draft' as UrlEncodedCommentId,
+ message: 'just for context',
+ updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+ line: 5,
+ context_lines: [
+ {line_number: 4, context_line: 'content of line 4'},
+ {line_number: 5, context_line: 'content of line 5'},
+ {line_number: 6, context_line: 'content of line 6'},
+ ],
+};
suite('gr-comment-thread tests', () => {
- suite('basic test', () => {
- let element: GrCommentThread;
+ let element: GrCommentThread;
- setup(async () => {
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- element = basicFixture.instantiate();
- element.patchNum = 3 as PatchSetNum;
- element.changeNum = 1 as NumericChangeId;
- await flush();
- });
+ setup(async () => {
+ stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+ element = basicFixture.instantiate();
+ element.changeNum = 1 as NumericChangeId;
+ element.showFileName = true;
+ element.showFilePath = true;
+ element.repoName = 'test-repo-name' as RepoName;
+ await element.updateComplete;
+ element.account = {...createAccountDetailWithId(13), name: 'Yoda'};
+ });
- test('renders', async () => {
- element.comments = [
- {
- ...createComment(),
- author: {name: 'Kermit'},
- id: 'the-root' as UrlEncodedCommentId,
- message: 'start the conversation',
- updated: '2021-11-01 10:11:12.000000000' as Timestamp,
- },
- {
- ...createComment(),
- author: {name: 'Ms Piggy'},
- id: 'the-reply' as UrlEncodedCommentId,
- message: 'keep it going',
- updated: '2021-11-02 10:11:12.000000000' as Timestamp,
- in_reply_to: 'the-root' as UrlEncodedCommentId,
- },
- {
- ...createComment(),
- author: {name: 'Kermit'},
- id: 'the-draft' as UrlEncodedCommentId,
- message: 'stop it',
- updated: '2021-11-03 10:11:12.000000000' as Timestamp,
- in_reply_to: 'the-reply' as UrlEncodedCommentId,
- __draft: true,
- },
- ];
- await flush();
- expect(element).shadowDom.to.equal(`
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ test('renders with draft', async () => {
+ element.thread = createThread(c1, c2, c3);
+ await element.updateComplete;
+ });
+
+ test('renders with draft', async () => {
+ element.thread = createThread(c1, c2, c3);
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="fileName">
+ <span>test-path-comment-thread</span>
+ <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+ </div>
+ <div class="pathInfo">
+ <span>#314</span>
+ </div>
<div id="container">
<h3 class="assistive-tech-only">Draft Comment thread by Kermit</h3>
<div class="comment-box" tabindex="0">
- <gr-comment></gr-comment>
- <gr-comment></gr-comment>
- <gr-comment></gr-comment>
- <dom-repeat as="comment" id="commentList" style="display: none;">
- <template is="dom-repeat"></template>
- </dom-repeat>
- <div hidden="true" id="commentInfoContainer">
- <span id="unresolvedLabel">Resolved</span>
+ <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+ <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+ <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders unsaved', async () => {
+ element.thread = createThread();
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="fileName">
+ <span>test-path-comment-thread</span>
+ <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+ </div>
+ <div class="pathInfo">
+ <span>#314</span>
+ </div>
+ <div id="container">
+ <h3 class="assistive-tech-only">Unresolved Draft Comment thread by Yoda</h3>
+ <div class="comment-box unresolved" tabindex="0">
+ <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders with actions resolved', async () => {
+ element.thread = createThread(c1, c2);
+ await element.updateComplete;
+ expect(queryAndAssert(element, '#container')).dom.to.equal(`
+ <div id="container">
+ <h3 class="assistive-tech-only">Comment thread by Kermit</h3>
+ <div class="comment-box" tabindex="0">
+ <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+ <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+ <div id="actionsContainer">
+ <span id="unresolvedLabel">
+ Resolved
+ </span>
<div id="actions">
- <iron-icon
- class="link-icon"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- title="Copy link to this comment"
- >
+ <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
</iron-icon>
- <gr-button
- aria-disabled="false"
- class="action reply"
- id="replyBtn"
- link=""
- role="button"
- tabindex="0"
- >
+ <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
Reply
</gr-button>
- <gr-button
- aria-disabled="false"
- class="action quote"
- id="quoteBtn"
- link=""
- role="button"
- tabindex="0"
- >
+ <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
Quote
</gr-button>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
</div>
</div>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
</div>
`);
- });
-
- test('comments are sorted correctly', () => {
- const comments: UIComment[] = [
- {
- message: 'i like you, too',
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- __date: new Date('2015-12-25'),
- },
- {
- id: 'sallys_confession' as UrlEncodedCommentId,
- message: 'i like you, jack',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i’m running away',
- updated: '2015-10-31 09:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_defiance' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i will poison you so i can get away',
- updated: '2015-10-31 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'dr_finklesteins_response' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'no i will pull a thread and your arm will fall off',
- updated: '2015-10-31 11:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_mission' as UrlEncodedCommentId,
- message: 'i have to find santa',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- ];
- const results = sortComments(comments);
- assert.deepEqual(results, [
- {
- id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i’m running away',
- updated: '2015-10-31 09:00:20.396000000' as Timestamp,
- },
- {
- id: 'dr_finklesteins_response' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'no i will pull a thread and your arm will fall off',
- updated: '2015-10-31 11:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_defiance' as UrlEncodedCommentId,
- in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- message: 'i will poison you so i can get away',
- updated: '2015-10-31 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_confession' as UrlEncodedCommentId,
- message: 'i like you, jack',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- {
- id: 'sallys_mission' as UrlEncodedCommentId,
- message: 'i have to find santa',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- },
- {
- message: 'i like you, too' as UrlEncodedCommentId,
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- __date: new Date('2015-12-25'),
- },
- ]);
- });
-
- test('addOrEditDraft w/ edit draft', () => {
- element.comments = [
- {
- id: 'jacks_reply' as UrlEncodedCommentId,
- message: 'i like you, too',
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- updated: '2015-12-25 15:00:20.396000000' as Timestamp,
- __draft: true,
- },
- ];
- const commentElStub = sinon
- .stub(element, '_commentElWithDraftID')
- .callsFake(() => new GrComment());
- const addDraftStub = sinon.stub(element, 'addDraft');
-
- element.addOrEditDraft(123);
-
- assert.isTrue(commentElStub.called);
- assert.isFalse(addDraftStub.called);
- });
-
- test('addOrEditDraft w/o edit draft', () => {
- element.comments = [];
- const commentElStub = sinon
- .stub(element, '_commentElWithDraftID')
- .callsFake(() => new GrComment());
- const addDraftStub = sinon.stub(element, 'addDraft');
-
- element.addOrEditDraft(123);
-
- assert.isFalse(commentElStub.called);
- assert.isTrue(addDraftStub.called);
- });
-
- test('_shouldDisableAction', () => {
- let showActions = true;
- const lastComment: UIComment = {};
- assert.equal(
- element._shouldDisableAction(showActions, lastComment),
- false
- );
- showActions = false;
- assert.equal(
- element._shouldDisableAction(showActions, lastComment),
- true
- );
- showActions = true;
- lastComment.__draft = true;
- assert.equal(
- element._shouldDisableAction(showActions, lastComment),
- true
- );
- const robotComment: UIRobot = {
- id: '1234' as UrlEncodedCommentId,
- updated: '1234' as Timestamp,
- robot_id: 'robot_id' as RobotId,
- robot_run_id: 'robot_run_id' as RobotRunId,
- properties: {},
- fix_suggestions: [],
- };
- assert.equal(
- element._shouldDisableAction(showActions, robotComment),
- false
- );
- });
-
- test('_hideActions', () => {
- let showActions = true;
- const lastComment: UIComment = {};
- assert.equal(element._hideActions(showActions, lastComment), false);
- showActions = false;
- assert.equal(element._hideActions(showActions, lastComment), true);
- showActions = true;
- lastComment.__draft = true;
- assert.equal(element._hideActions(showActions, lastComment), true);
- const robotComment: UIRobot = {
- id: '1234' as UrlEncodedCommentId,
- updated: '1234' as Timestamp,
- robot_id: 'robot_id' as RobotId,
- robot_run_id: 'robot_run_id' as RobotRunId,
- properties: {},
- fix_suggestions: [],
- };
- assert.equal(element._hideActions(showActions, robotComment), true);
- });
-
- test('setting project name loads the project config', async () => {
- const projectName = 'foo/bar/baz' as RepoName;
- const getProjectStub = stubRestApi('getProjectConfig').returns(
- Promise.resolve({} as ConfigInfo)
- );
- element.projectName = projectName;
- await flush();
- assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
- });
-
- test('optionally show file path', () => {
- // Path info doesn't exist when showFilePath is false. Because it's in a
- // dom-if it is not yet in the dom.
- assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
-
- const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
- element.changeNum = 123 as NumericChangeId;
- element.projectName = 'test project' as RepoName;
- element.path = 'path/to/file';
- element.patchNum = 3 as PatchSetNum;
- element.lineNum = 5;
- element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
- element.showFilePath = true;
- flush();
- assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
- assert.notEqual(
- getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
- .display,
- 'none'
- );
- assert.isTrue(
- commentStub.calledWithExactly(
- element.changeNum,
- element.projectName,
- 'comment_id' as UrlEncodedCommentId
- )
- );
- });
-
- test('_computeDisplayPath', () => {
- let path = 'path/to/file';
- assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
- element.lineNum = 5;
- assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
- element.patchNum = 3 as PatchSetNum;
- path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
- assert.equal(element._computeDisplayPath(path), 'Patchset');
- });
-
- test('_computeDisplayLine', () => {
- element.lineNum = 5;
- assert.equal(
- element._computeDisplayLine(element.lineNum, element.range),
- '#5'
- );
-
- element.path = SpecialFilePath.COMMIT_MESSAGE;
- element.lineNum = 5;
- assert.equal(
- element._computeDisplayLine(element.lineNum, element.range),
- '#5'
- );
-
- element.lineNum = undefined;
- element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
- assert.equal(
- element._computeDisplayLine(element.lineNum, element.range),
- ''
- );
- });
- });
-});
-
-suite('comment action tests with unresolved thread', () => {
- let element: GrCommentThread;
- let addDraftServiceStub: SinonStub;
- let saveDiffDraftStub: SinonStub;
- let comment = {
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Done',
- };
- const peanutButterComment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- message: 'is this a crossover episode!?',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- path: '/path/to/file.txt',
- unresolved: true,
- patch_set: 3 as PatchSetNum,
- };
- const mockResponse: Response = {
- ...new Response(),
- headers: {} as Headers,
- redirected: false,
- status: 200,
- statusText: '',
- type: '' as ResponseType,
- url: '',
- ok: true,
- text() {
- return Promise.resolve(")]}'\n" + JSON.stringify(comment));
- },
- };
- let saveDiffDraftPromiseResolver: (value?: Response) => void;
- setup(() => {
- addDraftServiceStub = stubComments('addDraft');
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- saveDiffDraftStub = stubRestApi('saveDiffDraft').returns(
- new Promise<Response>(
- resolve =>
- (saveDiffDraftPromiseResolver = resolve as (value?: Response) => void)
- )
- );
- stubRestApi('deleteDiffDraft').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- element = withCommentFixture.instantiate();
- element.patchNum = 1 as PatchSetNum;
- element.changeNum = 1 as NumericChangeId;
- element.comments = [peanutButterComment];
- flush();
});
- test('reply', () => {
- saveDiffDraftPromiseResolver(mockResponse);
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- const reportStub = stubReporting('recordDraftInteraction');
- assert.ok(commentEl);
-
- const replyBtn = element.$.replyBtn;
- tap(replyBtn);
- flush();
- const draft = addDraftServiceStub.firstCall.args[0];
- assert.isOk(draft);
- assert.notOk(draft.message, 'message should be empty');
- assert.equal(
- draft.in_reply_to,
- 'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
- );
- assert.isTrue(reportStub.calledOnce);
+ test('renders with actions unresolved', async () => {
+ element.thread = createThread(c1, {...c2, unresolved: true});
+ await element.updateComplete;
+ expect(queryAndAssert(element, '#container')).dom.to.equal(`
+ <div id="container">
+ <h3 class="assistive-tech-only">Unresolved Comment thread by Kermit</h3>
+ <div class="comment-box unresolved" tabindex="0">
+ <gr-comment show-patchset=""></gr-comment>
+ <gr-comment show-patchset=""></gr-comment>
+ <div id="actionsContainer">
+ <span id="unresolvedLabel">
+ Unresolved
+ </span>
+ <div id="actions">
+ <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
+ </iron-icon>
+ <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
+ Reply
+ </gr-button>
+ <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
+ Quote
+ </gr-button>
+ <gr-button aria-disabled="false" class="action ack" id="ackBtn" link="" role="button" tabindex="0">
+ Ack
+ </gr-button>
+ <gr-button aria-disabled="false" class="action done" id="doneBtn" link="" role="button" tabindex="0">
+ Done
+ </gr-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ `);
});
- test('quote reply', () => {
- saveDiffDraftPromiseResolver(mockResponse);
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- const reportStub = stubReporting('recordDraftInteraction');
- assert.ok(commentEl);
-
- const quoteBtn = element.$.quoteBtn;
- tap(quoteBtn);
- flush();
-
- const draft = addDraftServiceStub.firstCall.args[0];
- // the quote reply is not autmatically saved so verify that id is not set
- assert.isNotOk(draft.id);
- // verify that the draft returned was not saved
- assert.isNotOk(saveDiffDraftStub.called);
- assert.equal(draft.message, '> is this a crossover episode!?\n\n');
- assert.equal(
- draft.in_reply_to,
- 'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
- );
- assert.isTrue(reportStub.calledOnce);
+ test('renders with diff', async () => {
+ element.showCommentContext = true;
+ element.thread = createThread(commentWithContext);
+ await element.updateComplete;
+ expect(queryAndAssert(element, '.diff-container')).dom.to.equal(`
+ <div class="diff-container">
+ <gr-diff
+ class="disable-context-control-buttons hide-line-length-indicator no-left"
+ id="diff"
+ style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
+ >
+ </gr-diff>
+ <div class="view-diff-container">
+ <a href="">
+ <gr-button aria-disabled="false" class="view-diff-button" link="" role="button" tabindex="0">
+ View Diff
+ </gr-button>
+ </a>
+ </div>
+ </div>
+ `);
});
- test('quote reply multiline', () => {
- saveDiffDraftPromiseResolver(mockResponse);
- const reportStub = stubReporting('recordDraftInteraction');
- element.comments = [
+ suite('action button clicks', () => {
+ let savePromise: MockPromise<void>;
+ let stub: SinonStub;
+
+ setup(async () => {
+ savePromise = mockPromise<void>();
+ stub = stubComments('saveDraft').returns(savePromise);
+
+ element.thread = createThread(c1, {...c2, unresolved: true});
+ await element.updateComplete;
+ });
+
+ test('handle Ack', async () => {
+ tap(queryAndAssert(element, '#ackBtn'));
+ waitUntilCalled(stub, 'saveDraft()');
+ assert.equal(stub.lastCall.firstArg.message, 'Ack');
+ assert.equal(stub.lastCall.firstArg.unresolved, false);
+ assert.isTrue(element.saving);
+
+ savePromise.resolve();
+ await element.updateComplete;
+ assert.isFalse(element.saving);
+ });
+
+ test('handle Done', async () => {
+ tap(queryAndAssert(element, '#doneBtn'));
+ waitUntilCalled(stub, 'saveDraft()');
+ assert.equal(stub.lastCall.firstArg.message, 'Done');
+ assert.equal(stub.lastCall.firstArg.unresolved, false);
+ });
+
+ test('handle Reply', async () => {
+ assert.isUndefined(element.unsavedComment);
+ tap(queryAndAssert(element, '#replyBtn'));
+ assert.equal(element.unsavedComment?.message, '');
+ });
+
+ test('handle Quote', async () => {
+ assert.isUndefined(element.unsavedComment);
+ tap(queryAndAssert(element, '#quoteBtn'));
+ assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
+ });
+ });
+
+ test('comments are sorted correctly', () => {
+ const comments: CommentInfo[] = [
{
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- path: 'test',
- line: 5,
- message: 'is this a crossover episode!?\nIt might be!',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+ id: 'jacks_confession' as UrlEncodedCommentId,
+ in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, too',
+ updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, jack',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i’m running away',
+ updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sallys_defiance' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i will poison you so i can get away',
+ updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'no i will pull a thread and your arm will fall off',
+ updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'sallys_mission' as UrlEncodedCommentId,
+ message: 'i have to find santa',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
},
];
- flush();
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const quoteBtn = element.$.quoteBtn;
- tap(quoteBtn);
- flush();
-
- const draft = addDraftServiceStub.firstCall.args[0];
- assert.equal(
- draft.message,
- '> is this a crossover episode!?\n> It might be!\n\n'
- );
- assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('ack', async () => {
- saveDiffDraftPromiseResolver(mockResponse);
- comment = {
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Ack',
- };
- const reportStub = stubReporting('recordDraftInteraction');
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
- assert.isOk(ackBtn);
- tap(ackBtn!);
- await flush();
- const draft = addDraftServiceStub.firstCall.args[0];
- assert.equal(draft.message, 'Ack');
- assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
- assert.isNotOk(draft.unresolved);
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('done', async () => {
- saveDiffDraftPromiseResolver(mockResponse);
- comment = {
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Done',
- };
- const reportStub = stubReporting('recordDraftInteraction');
- assert.isFalse(saveDiffDraftStub.called);
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
- assert.isOk(doneBtn);
- tap(doneBtn!);
- await flush();
- const draft = addDraftServiceStub.firstCall.args[0];
- // Since the reply is automatically saved, verify that draft.id is set in
- // the model
- assert.equal(draft.id, '7afa4931_de3d65bd');
- assert.equal(draft.message, 'Done');
- assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
- assert.isNotOk(draft.unresolved);
- assert.isTrue(reportStub.calledOnce);
- assert.isTrue(saveDiffDraftStub.called);
- });
-
- test('save', async () => {
- saveDiffDraftPromiseResolver(mockResponse);
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.path = '/path/to/file.txt';
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- element.shadowRoot?.querySelector('gr-comment')?._fireSave();
-
- await flush();
- assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
- });
-
- test('please fix', async () => {
- comment = peanutButterComment;
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
- const promise = mockPromise();
- commentEl!.addEventListener('create-fix-comment', async () => {
- assert.isTrue(saveDiffDraftStub.called);
- assert.isFalse(addDraftServiceStub.called);
- saveDiffDraftPromiseResolver(mockResponse);
- // flushing so the saveDiffDraftStub resolves and the draft is returned
- await flush();
- assert.isTrue(saveDiffDraftStub.called);
- assert.isTrue(addDraftServiceStub.called);
- const draft = saveDiffDraftStub.firstCall.args[2];
- assert.equal(
- draft.message,
- '> is this a crossover episode!?\n\nPlease fix.'
- );
- assert.equal(
- draft.in_reply_to,
- 'baf0414d_60047215' as UrlEncodedCommentId
- );
- assert.isTrue(draft.unresolved);
- promise.resolve();
- });
- assert.isFalse(saveDiffDraftStub.called);
- assert.isFalse(addDraftServiceStub.called);
- commentEl!.dispatchEvent(
- new CustomEvent('create-fix-comment', {
- detail: {comment: commentEl!.comment},
- composed: true,
- bubbles: false,
- })
- );
- await promise;
- });
-
- test('discard', async () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.path = '/path/to/file.txt';
- assert.isOk(element.comments[0]);
- const deleteDraftStub = stubComments('deleteDraft');
- element.push(
- 'comments',
- element._newReply(
- element.comments[0]!.id as UrlEncodedCommentId,
- 'it’s pronouced jiff, not giff'
- )
- );
- await flush();
-
- const draftEl = element.root?.querySelectorAll('gr-comment')[1];
- assert.ok(draftEl);
- draftEl?._fireSave(); // tell the model about the draft
- const promise = mockPromise();
- draftEl!.addEventListener('comment-discard', () => {
- assert.isTrue(deleteDraftStub.called);
- promise.resolve();
- });
- draftEl!._fireDiscard();
- await promise;
- });
-
- test('discard with a single comment still fires event with previous rootId', async () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.path = '/path/to/file.txt';
- element.comments = [];
- element.addOrEditDraft(1 as LineNumber);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [draft];
- flush();
- const rootId = element.rootId;
- assert.isOk(rootId);
- flush();
- const draftEl = element.root?.querySelectorAll('gr-comment')[0];
- assert.ok(draftEl);
- const deleteDraftStub = stubComments('deleteDraft');
- const promise = mockPromise();
- draftEl!.addEventListener('comment-discard', () => {
- assert.isTrue(deleteDraftStub.called);
- promise.resolve();
- });
- draftEl!._fireDiscard();
- await promise;
- assert.isTrue(deleteDraftStub.called);
- });
-
- test('comment-update', () => {
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- const updatedComment = {
- id: element.comments[0].id,
- foo: 'bar',
- };
- assert.isOk(commentEl);
- commentEl!.dispatchEvent(
- new CustomEvent('comment-update', {
- detail: {comment: updatedComment},
- composed: true,
- bubbles: true,
- })
- );
- assert.strictEqual(element.comments[0], updatedComment);
- });
-
- suite('jack and sally comment data test consolidation', () => {
- setup(() => {
- element.comments = [
- {
- id: 'jacks_reply' as UrlEncodedCommentId,
- message: 'i like you, too',
- in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
- updated: '2015-12-25 15:00:20.396000000' as Timestamp,
- path: 'abcd',
- unresolved: false,
- },
- {
- id: 'sallys_confession' as UrlEncodedCommentId,
- in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
- message: 'i like you, jack',
- updated: '2015-12-24 15:00:20.396000000' as Timestamp,
- path: 'abcd',
- },
- {
- id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
- in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
- message: 'i’m running away',
- updated: '2015-10-31 09:00:20.396000000' as Timestamp,
- path: 'abcd',
- },
- {
- id: 'sallys_defiance' as UrlEncodedCommentId,
- message: 'i will poison you so i can get away',
- updated: '2015-10-31 15:00:20.396000000' as Timestamp,
- path: 'abcd',
- },
- ];
- });
-
- test('orphan replies', () => {
- assert.equal(4, element._orderedComments.length);
- });
-
- test('keyboard shortcuts', () => {
- const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
- pressAndReleaseKeyOn(element, 69, null, 'e');
- assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
- pressAndReleaseKeyOn(element, 69, 'shift', 'E');
- assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
- });
-
- test('comment in_reply_to is either null or most recent comment', () => {
- element._createReplyComment('dummy', true);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [...element.comments, draft];
- flush();
- assert.equal(element._orderedComments.length, 5);
- assert.equal(
- element._orderedComments[4].in_reply_to,
- 'jacks_reply' as UrlEncodedCommentId
- );
- });
-
- test('resolvable comments', () => {
- assert.isFalse(element.unresolved);
- element._createReplyComment('dummy', true, true);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [...element.comments, draft];
- flush();
- assert.isTrue(element.unresolved);
- });
-
- test('_setInitialExpandedState with unresolved', () => {
- element.unresolved = true;
- element._setInitialExpandedState();
- for (let i = 0; i < element.comments.length; i++) {
- assert.isFalse(element.comments[i].collapsed);
- }
- });
-
- test('_setInitialExpandedState without unresolved', () => {
- element.unresolved = false;
- element._setInitialExpandedState();
- for (let i = 0; i < element.comments.length; i++) {
- assert.isTrue(element.comments[i].collapsed);
- }
- });
-
- test('_setInitialExpandedState with robot_ids', () => {
- for (let i = 0; i < element.comments.length; i++) {
- (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
- }
- element._setInitialExpandedState();
- for (let i = 0; i < element.comments.length; i++) {
- assert.isFalse(element.comments[i].collapsed);
- }
- });
-
- test('_setInitialExpandedState with collapsed state', () => {
- element.comments[0].collapsed = false;
- element.unresolved = false;
- element._setInitialExpandedState();
- assert.isFalse(element.comments[0].collapsed);
- for (let i = 1; i < element.comments.length; i++) {
- assert.isTrue(element.comments[i].collapsed);
- }
- });
- });
-
- test('_computeHostClass', () => {
- assert.equal(element._computeHostClass(true), 'unresolved');
- assert.equal(element._computeHostClass(false), '');
- });
-
- test('addDraft sets unresolved state correctly', () => {
- let unresolved = true;
- let draft;
- element.comments = [];
- element.path = 'abcd';
- element.addDraft(undefined, undefined, unresolved);
- draft = addDraftServiceStub.lastCall.args[0];
- assert.equal(draft.unresolved, true);
-
- unresolved = false; // comment should get added as actually resolved.
- element.comments = [];
- element.addDraft(undefined, undefined, unresolved);
- draft = addDraftServiceStub.lastCall.args[0];
- assert.equal(draft.unresolved, false);
-
- element.comments = [];
- element.addDraft();
- draft = addDraftServiceStub.lastCall.args[0];
- assert.equal(draft.unresolved, true);
- });
-
- test('_newDraft with root', () => {
- const draft = element._newDraft();
- assert.equal(draft.patch_set, 3 as PatchSetNum);
- });
-
- test('_newDraft with no root', () => {
- element.comments = [];
- element.diffSide = Side.RIGHT;
- element.patchNum = 2 as PatchSetNum;
- const draft = element._newDraft();
- assert.equal(draft.patch_set, 2 as PatchSetNum);
- });
-
- test('new comment gets created', () => {
- element.comments = [];
- element.path = 'abcd';
- element.addOrEditDraft(1);
- const draft = addDraftServiceStub.firstCall.args[0];
- element.comments = [draft];
- flush();
- assert.equal(element.comments.length, 1);
- // Mock a submitted comment.
- element.comments[0].id = (element.comments[0] as UIDraft)
- .__draftID as UrlEncodedCommentId;
- delete (element.comments[0] as UIDraft).__draft;
- element.addOrEditDraft(1);
- assert.equal(addDraftServiceStub.callCount, 2);
- });
-
- test('unresolved label', () => {
- element.unresolved = false;
- const label = element.shadowRoot?.querySelector('#unresolvedLabel');
- assert.isOk(label);
- assert.isFalse(label!.hasAttribute('hidden'));
- element.unresolved = true;
- assert.isFalse(label!.hasAttribute('hidden'));
- });
-
- test('draft comments are at the end of orderedComments', () => {
- element.comments = [
+ const results = sortComments(comments);
+ assert.deepEqual(results, [
{
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: '2' as UrlEncodedCommentId,
- line: 5,
- message: 'Earlier draft',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- __draft: true,
+ id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i’m running away',
+ updated: '2015-10-31 09:00:20.396000000' as Timestamp,
},
{
- author: {
- name: 'Mr. Peanutbutter2',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: '1' as UrlEncodedCommentId,
- line: 5,
- message: 'This comment was left last but is not a draft',
- updated: '2015-12-10 19:48:33.843000000' as Timestamp,
+ id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'no i will pull a thread and your arm will fall off',
+ updated: '2015-10-31 11:00:20.396000000' as Timestamp,
},
{
- author: {
- name: 'Mr. Peanutbutter2',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: '3' as UrlEncodedCommentId,
- line: 5,
- message: 'Later draft',
- updated: '2015-12-09 19:48:33.843000000' as Timestamp,
- __draft: true,
+ id: 'sallys_defiance' as UrlEncodedCommentId,
+ in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+ message: 'i will poison you so i can get away',
+ updated: '2015-10-31 15:00:20.396000000' as Timestamp,
},
- ];
- assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
- assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
- assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
- });
-
- test('reflects lineNum and commentSide to attributes', () => {
- element.lineNum = 7;
- element.diffSide = Side.LEFT;
-
- assert.equal(element.getAttribute('line-num'), '7');
- assert.equal(element.getAttribute('diff-side'), Side.LEFT);
- });
-
- test('reflects range to JSON serialized attribute if set', () => {
- element.range = {
- start_line: 4,
- end_line: 5,
- start_character: 6,
- end_character: 7,
- };
-
- assert.isOk(element.getAttribute('range'));
- assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
- start_line: 4,
- end_line: 5,
- start_character: 6,
- end_character: 7,
- });
- });
-
- test('removes range attribute if range is unset', () => {
- element.range = {
- start_line: 4,
- end_line: 5,
- start_character: 6,
- end_character: 7,
- };
- element.range = undefined;
-
- assert.notOk(element.hasAttribute('range'));
- });
-});
-
-suite('comment action tests on resolved comments', () => {
- let element: GrCommentThread;
-
- setup(() => {
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- stubRestApi('saveDiffDraft').returns(
- Promise.resolve({
- ...new Response(),
- ok: true,
- text() {
- return Promise.resolve(
- ")]}'\n" +
- JSON.stringify({
- id: '7afa4931_de3d65bd',
- path: '/path/to/file.txt',
- line: 5,
- in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
- updated: '2015-12-21 02:01:10.850000000',
- message: 'Done',
- })
- );
- },
- })
- );
- stubRestApi('deleteDiffDraft').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- element = withCommentFixture.instantiate();
- element.patchNum = 1 as PatchSetNum;
- element.changeNum = 1 as NumericChangeId;
- element.comments = [
{
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- line: 5,
- message: 'is this a crossover episode!?',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- path: '/path/to/file.txt',
- unresolved: false,
+ id: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, jack',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
},
- ];
- flush();
- });
-
- test('ack and done should be hidden', () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
-
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
- const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
- assert.equal(ackBtn, null);
- assert.equal(doneBtn, null);
- });
-
- test('reply and quote button should be visible', () => {
- const commentEl = element.shadowRoot?.querySelector('gr-comment');
- assert.ok(commentEl);
-
- const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
- const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
- assert.ok(replyBtn);
- assert.ok(quoteBtn);
+ {
+ id: 'sallys_mission' as UrlEncodedCommentId,
+ message: 'i have to find santa',
+ updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+ },
+ {
+ id: 'jacks_confession' as UrlEncodedCommentId,
+ in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+ message: 'i like you, too',
+ updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+ },
+ ]);
});
});
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 0c58c33..1411c88 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -27,53 +27,50 @@
import '../gr-tooltip-content/gr-tooltip-content';
import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
import '../gr-account-label/gr-account-label';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment_html';
-import {getRootElement} from '../../../scripts/rootElement';
import {getAppContext} from '../../../services/app-context';
-import {customElement, observe, property} from '@polymer/decorators';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {GrTextarea} from '../gr-textarea/gr-textarea';
import {GrOverlay} from '../gr-overlay/gr-overlay';
import {
AccountDetailInfo,
- BasePatchSetNum,
- ConfigInfo,
+ CommentLinks,
NumericChangeId,
- PatchSetNum,
RepoName,
+ RobotCommentInfo,
} from '../../../types/common';
-import {GrButton} from '../gr-button/gr-button';
import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
import {
- isDraft,
+ Comment,
+ isDraftOrUnsaved,
isRobot,
- UIComment,
- UIDraft,
- UIRobot,
+ isUnsaved,
} from '../../../utils/comment-util';
-import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
-import {pluralize} from '../../../utils/string-util';
+import {
+ OpenFixPreviewEventDetail,
+ ValueChangedEvent,
+} from '../../../types/events';
+import {fire, fireEvent} from '../../../utils/event-util';
import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
-import {Interaction} from '../../../constants/reporting';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {classMap} from 'lit/directives/class-map';
+import {LineNumber} from '../../../api/diff';
+import {CommentSide} from '../../../constants/constants';
+import {getRandomInt} from '../../../utils/math-util';
+import {Subject} from 'rxjs';
+import {debounceTime} from 'rxjs/operators';
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVED_MESSAGE = 'All changes saved';
const UNSAVED_MESSAGE = 'Unable to save draft';
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
const FILE = 'FILE';
+// visible for testing
+export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
+
export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
/**
@@ -88,25 +85,21 @@
'When disagreeing, explain the advantage of your approach.',
];
-interface CommentOverlays {
- confirmDelete?: GrOverlay | null;
- confirmDiscard?: GrOverlay | null;
+declare global {
+ interface HTMLElementEventMap {
+ 'comment-editing-changed': CustomEvent<boolean>;
+ 'comment-unresolved-changed': CustomEvent<boolean>;
+ 'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
+ }
}
-export interface GrComment {
- $: {
- container: HTMLDivElement;
- resolvedCheckbox: HTMLInputElement;
- header: HTMLDivElement;
- };
+export interface CommentAnchorTapEventDetail {
+ number: LineNumber;
+ side?: CommentSide;
}
@customElement('gr-comment')
-export class GrComment extends PolymerElement {
- static get template() {
- return htmlTemplate;
- }
-
+export class GrComment extends LitElement {
/**
* Fired when the create fix comment action is triggered.
*
@@ -120,30 +113,6 @@
*/
/**
- * Fired when this comment is discarded.
- *
- * @event comment-discard
- */
-
- /**
- * Fired when this comment is edited.
- *
- * @event comment-edit
- */
-
- /**
- * Fired when this comment is saved.
- *
- * @event comment-save
- */
-
- /**
- * Fired when this comment is updated.
- *
- * @event comment-update
- */
-
- /**
* Fired when editing status changed.
*
* @event comment-editing-changed
@@ -155,124 +124,102 @@
* @event comment-anchor-tap
*/
- @property({type: Number})
- changeNum?: NumericChangeId;
+ @query('#editTextarea')
+ textarea?: GrTextarea;
- @property({type: String})
- projectName?: RepoName;
+ @query('#container')
+ container?: HTMLElement;
- @property({type: Object, notify: true, observer: '_commentChanged'})
- comment?: UIComment;
+ @query('#resolvedCheckbox')
+ resolvedCheckbox?: HTMLInputElement;
+ @query('#confirmDeleteOverlay')
+ confirmDeleteOverlay?: GrOverlay;
+
+ @property({type: Object})
+ comment?: Comment;
+
+ // TODO: Move this out of gr-comment. gr-comment should not have a comments
+ // property. This is only used for hasHumanReply at the moment.
@property({type: Array})
- comments?: UIComment[];
-
- @property({type: Boolean, reflectToAttribute: true})
- isRobotComment = false;
-
- @property({type: Boolean, reflectToAttribute: true})
- disabled = false;
-
- @property({type: Boolean, observer: '_draftChanged'})
- draft = false;
-
- @property({type: Boolean, observer: '_editingChanged'})
- editing = false;
-
- // Assigns a css property to the comment hiding the comment while it's being
- // discarded
- @property({
- type: Boolean,
- reflectToAttribute: true,
- })
- discarding = false;
-
- @property({type: Boolean})
- hasChildren?: boolean;
-
- @property({type: String})
- patchNum?: PatchSetNum;
-
- @property({type: Boolean})
- showActions?: boolean;
-
- @property({type: Boolean})
- _showHumanActions?: boolean;
-
- @property({type: Boolean})
- _showRobotActions?: boolean;
-
- @property({
- type: Boolean,
- reflectToAttribute: true,
- observer: '_toggleCollapseClass',
- })
- collapsed = true;
-
- @property({type: Object})
- projectConfig?: ConfigInfo;
-
- @property({type: Boolean})
- robotButtonDisabled = false;
-
- @property({type: Boolean})
- _hasHumanReply?: boolean;
-
- @property({type: Boolean})
- _isAdmin = false;
-
- @property({type: Object})
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- _xhrPromise?: Promise<any>; // Used for testing.
-
- @property({type: String, observer: '_messageTextChanged'})
- _messageText = '';
-
- @property({type: String})
- side?: string;
-
- @property({type: Boolean})
- resolved = false;
-
- // Intentional to share the object across instances.
- @property({type: Object})
- _numPendingDraftRequests: {number: number} = {number: 0};
-
- @property({type: Boolean})
- _enableOverlay = false;
+ comments?: Comment[];
/**
- * Property for storing references to overlay elements. When the overlays
- * are moved to getRootElement() to be shown they are no-longer
- * children, so they can't be queried along the tree, so they are stored
- * here.
+ * Initial collapsed state of the comment.
*/
- @property({type: Object})
- _overlays: CommentOverlays = {};
+ @property({type: Boolean, attribute: 'initially-collapsed'})
+ initiallyCollapsed?: boolean;
+
+ /**
+ * This is the *current* (internal) collapsed state of the comment. Do not set
+ * from the outside. Use `initiallyCollapsed` instead. This is just a
+ * reflected property such that css rules can be based on it.
+ */
+ @property({type: Boolean, reflect: true})
+ collapsed?: boolean;
+
+ @property({type: Boolean, attribute: 'robot-button-disabled'})
+ robotButtonDisabled = false;
+
+ /* internal only, but used in css rules */
+ @property({type: Boolean, reflect: true})
+ saving = false;
+
+ /**
+ * `saving` and `autoSaving` are separate and cannot be set at the same time.
+ * `saving` affects the UI state (disabled buttons, etc.) and eventually
+ * leaves editing mode, but `autoSaving` just happens in the background
+ * without the user noticing.
+ */
+ @state()
+ autoSaving?: Promise<void>;
+
+ @state()
+ changeNum?: NumericChangeId;
+
+ @state()
+ editing = false;
+
+ @state()
+ commentLinks: CommentLinks = {};
+
+ @state()
+ repoName?: RepoName;
+
+ /* The 'dirty' state of the comment.message, which will be saved on demand. */
+ @state()
+ messageText = '';
+
+ /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
+ @state()
+ unresolved = true;
@property({type: Boolean})
- _showRespectfulTip = false;
+ showConfirmDeleteOverlay = false;
@property({type: Boolean})
- showPatchset = true;
+ showRespectfulTip = false;
@property({type: String})
- _respectfulReviewTip?: string;
+ respectfulReviewTip?: string;
@property({type: Boolean})
- _respectfulTipDismissed = false;
+ respectfulTipDismissed = false;
@property({type: Boolean})
- _unableToSave = false;
+ unableToSave = false;
- @property({type: Object})
- _selfAccount?: AccountDetailInfo;
+ @property({type: Boolean, attribute: 'show-patchset'})
+ showPatchset = false;
- @property({type: Boolean})
+ @property({type: Boolean, attribute: 'show-ported-comment'})
showPortedComment = false;
- /** Called in disconnectedCallback. */
- private cleanups: (() => void)[] = [];
+ @state()
+ account?: AccountDetailInfo;
+
+ @state()
+ isAdmin = false;
private readonly restApiService = getAppContext().restApiService;
@@ -280,67 +227,700 @@
private readonly reporting = getAppContext().reportingService;
- private readonly commentsService = getAppContext().commentsService;
+ private readonly changeModel = getAppContext().changeModel;
- private fireUpdateTask?: DelayedTask;
+ private readonly commentsModel = getAppContext().commentsModel;
- private storeTask?: DelayedTask;
+ private readonly userModel = getAppContext().userModel;
- private draftToastTask?: DelayedTask;
+ private readonly configModel = getAppContext().configModel;
- override connectedCallback() {
- super.connectedCallback();
- this.restApiService.getAccount().then(account => {
- this._selfAccount = account;
- });
- if (this.editing) {
- this.collapsed = false;
- } else if (this.comment) {
- this.collapsed = !!this.comment.collapsed;
- }
- this._getIsAdmin().then(isAdmin => {
- this._isAdmin = !!isAdmin;
- });
- this.cleanups.push(
- addShortcut(this, {key: Key.ESC}, () => this._handleEsc())
+ private readonly shortcuts = new ShortcutController(this);
+
+ /**
+ * This is triggered when the user types into the editing textarea. We then
+ * debounce it and call autoSave().
+ */
+ private autoSaveTrigger$ = new Subject();
+
+ /**
+ * Set to the content of DraftInfo when entering editing mode.
+ * Only used for "Cancel".
+ */
+ private originalMessage = '';
+
+ /**
+ * Set to the content of DraftInfo when entering editing mode.
+ * Only used for "Cancel".
+ */
+ private originalUnresolved = false;
+
+ constructor() {
+ super();
+ subscribe(this, this.userModel.account$, x => (this.account = x));
+ subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
+ subscribe(
+ this,
+ this.configModel.repoCommentLinks$,
+ x => (this.commentLinks = x)
);
+ subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+ subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+ subscribe(
+ this,
+ this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+ () => {
+ this.autoSave();
+ }
+ );
+ this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc());
for (const key of ['s', Key.ENTER]) {
for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
- addShortcut(this, {key, modifiers: [modifier]}, e =>
- this._handleSaveKey(e)
- );
+ this.shortcuts.addLocal({key, modifiers: [modifier]}, () => {
+ this.save();
+ });
}
}
}
override disconnectedCallback() {
- for (const cleanup of this.cleanups) cleanup();
- this.cleanups = [];
- this.fireUpdateTask?.cancel();
- this.storeTask?.cancel();
- this.draftToastTask?.cancel();
- if (this.textarea) {
- this.textarea.closeDropdown();
- }
+ // Clean up emoji dropdown.
+ if (this.textarea) this.textarea.closeDropdown();
super.disconnectedCallback();
}
- /** 2nd argument is for *triggering* the computation only. */
- _getAuthor(comment?: UIComment, _?: unknown) {
- return comment?.author || this._selfAccount;
+ static override get styles() {
+ return [
+ sharedStyles,
+ css`
+ :host {
+ display: block;
+ font-family: var(--font-family);
+ padding: var(--spacing-m);
+ }
+ :host([collapsed]) {
+ padding: var(--spacing-s) var(--spacing-m);
+ }
+ :host([saving]) {
+ pointer-events: none;
+ }
+ :host([saving]) .actions,
+ :host([saving]) .robotActions,
+ :host([saving]) .date {
+ opacity: 0.5;
+ }
+ .body {
+ padding-top: var(--spacing-m);
+ }
+ .header {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ }
+ .headerLeft > span {
+ font-weight: var(--font-weight-bold);
+ }
+ .headerMiddle {
+ color: var(--deemphasized-text-color);
+ flex: 1;
+ overflow: hidden;
+ }
+ .draftLabel,
+ .draftTooltip {
+ color: var(--deemphasized-text-color);
+ display: inline;
+ }
+ .date {
+ justify-content: flex-end;
+ text-align: right;
+ white-space: nowrap;
+ }
+ span.date {
+ color: var(--deemphasized-text-color);
+ }
+ span.date:hover {
+ text-decoration: underline;
+ }
+ .actions,
+ .robotActions {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 0;
+ }
+ .robotActions {
+ /* Better than the negative margin would be to remove the gr-button
+ * padding, but then we would also need to fix the buttons that are
+ * inserted by plugins. :-/ */
+ margin: 4px 0 -4px;
+ }
+ .action {
+ margin-left: var(--spacing-l);
+ }
+ .rightActions {
+ display: flex;
+ justify-content: flex-end;
+ }
+ .rightActions gr-button {
+ --gr-button-padding: 0 var(--spacing-s);
+ }
+ .editMessage {
+ display: block;
+ margin: var(--spacing-m) 0;
+ width: 100%;
+ }
+ .show-hide {
+ margin-left: var(--spacing-s);
+ }
+ .robotId {
+ color: var(--deemphasized-text-color);
+ margin-bottom: var(--spacing-m);
+ }
+ .robotRun {
+ margin-left: var(--spacing-m);
+ }
+ .robotRunLink {
+ margin-left: var(--spacing-m);
+ }
+ /* just for a11y */
+ input.show-hide {
+ display: none;
+ }
+ label.show-hide {
+ cursor: pointer;
+ display: block;
+ }
+ label.show-hide iron-icon {
+ vertical-align: top;
+ }
+ :host([collapsed]) #container .body {
+ padding-top: 0;
+ }
+ #container .collapsedContent {
+ display: block;
+ overflow: hidden;
+ padding-left: var(--spacing-m);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .resolve,
+ .unresolved {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ margin: 0;
+ }
+ .resolve label {
+ color: var(--comment-text-color);
+ }
+ gr-dialog .main {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ }
+ #deleteBtn {
+ --gr-button-text-color: var(--deemphasized-text-color);
+ --gr-button-padding: 0;
+ }
+
+ /** Disable select for the caret and actions */
+ .actions,
+ .show-hide {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ .respectfulReviewTip {
+ justify-content: space-between;
+ display: flex;
+ padding: var(--spacing-m);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ margin-bottom: var(--spacing-m);
+ }
+ .respectfulReviewTip div {
+ display: flex;
+ }
+ .respectfulReviewTip div iron-icon {
+ margin-right: var(--spacing-s);
+ }
+ .respectfulReviewTip a {
+ white-space: nowrap;
+ margin-right: var(--spacing-s);
+ padding-left: var(--spacing-m);
+ text-decoration: none;
+ }
+ .pointer {
+ cursor: pointer;
+ }
+ .patchset-text {
+ color: var(--deemphasized-text-color);
+ margin-left: var(--spacing-s);
+ }
+ .headerLeft gr-account-label {
+ --account-max-length: 130px;
+ width: 150px;
+ }
+ .headerLeft gr-account-label::part(gr-account-label-text) {
+ font-weight: var(--font-weight-bold);
+ }
+ .draft gr-account-label {
+ width: unset;
+ }
+ .portedMessage {
+ margin: 0 var(--spacing-m);
+ }
+ .link-icon {
+ cursor: pointer;
+ }
+ `,
+ ];
}
- _getUrlForComment(comment?: UIComment) {
- if (!comment || !this.changeNum || !this.projectName) return '';
+ override render() {
+ if (isUnsaved(this.comment) && !this.editing) return;
+ const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+ return html`
+ <div id="container" class="${classMap(classes)}">
+ <div
+ class="header"
+ id="header"
+ @click="${() => (this.collapsed = !this.collapsed)}"
+ >
+ <div class="headerLeft">
+ ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
+ ${this.renderDraftLabel()}
+ </div>
+ <div class="headerMiddle">${this.renderCollapsedContent()}</div>
+ ${this.renderRunDetails()} ${this.renderDeleteButton()}
+ ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+ </div>
+ <div class="body">
+ ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+ ${this.renderRespectfulTip()} ${this.renderCommentMessage()}
+ ${this.renderHumanActions()} ${this.renderRobotActions()}
+ </div>
+ </div>
+ ${this.renderConfirmDialog()}
+ `;
+ }
+
+ private renderAuthor() {
+ if (isRobot(this.comment)) {
+ const id = this.comment.robot_id;
+ return html`<span class="robotName">${id}</span>`;
+ }
+ const classes = {draft: isDraftOrUnsaved(this.comment)};
+ return html`
+ <gr-account-label
+ .account="${this.comment?.author ?? this.account}"
+ class="${classMap(classes)}"
+ hideStatus
+ >
+ </gr-account-label>
+ `;
+ }
+
+ private renderPortedCommentMessage() {
+ if (!this.showPortedComment) return;
+ if (!this.comment?.patch_set) return;
+ return html`
+ <a href="${this.getUrlForComment()}">
+ <span class="portedMessage" @click="${this.handlePortedMessageClick}">
+ From patchset ${this.comment?.patch_set}]]
+ </span>
+ </a>
+ `;
+ }
+
+ private renderDraftLabel() {
+ if (!isDraftOrUnsaved(this.comment)) return;
+ let label = 'DRAFT';
+ let tooltip =
+ 'This draft is only visible to you. ' +
+ "To publish drafts, click the 'Reply' or 'Start review' button " +
+ "at the top of the change or press the 'a' key.";
+ if (this.unableToSave) {
+ label += ' (Failed to save)';
+ tooltip = 'Unable to save draft. Please try to save again.';
+ }
+ return html`
+ <gr-tooltip-content
+ class="draftTooltip"
+ has-tooltip
+ title="${tooltip}"
+ max-width="20em"
+ show-icon
+ >
+ <span class="draftLabel">${label}</span>
+ </gr-tooltip-content>
+ `;
+ }
+
+ private renderCollapsedContent() {
+ if (!this.collapsed) return;
+ return html`
+ <span class="collapsedContent">${this.comment?.message}</span>
+ `;
+ }
+
+ private renderRunDetails() {
+ if (!isRobot(this.comment)) return;
+ if (!this.comment?.url || this.collapsed) return;
+ return html`
+ <div class="runIdMessage message">
+ <div class="runIdInformation">
+ <a class="robotRunLink" href="${this.comment.url}">
+ <span class="robotRun link">Run Details</span>
+ </a>
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Deleting a comment is an admin feature. It means more than just discarding
+ * a draft. It is an action applied to published comments.
+ */
+ private renderDeleteButton() {
+ if (
+ !this.isAdmin ||
+ isDraftOrUnsaved(this.comment) ||
+ isRobot(this.comment)
+ )
+ return;
+ if (this.collapsed) return;
+ return html`
+ <gr-button
+ id="deleteBtn"
+ title="Delete Comment"
+ link
+ class="action delete"
+ @click="${this.openDeleteCommentOverlay}"
+ >
+ <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+ </gr-button>
+ `;
+ }
+
+ private renderPatchset() {
+ if (!this.showPatchset) return;
+ assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+ return html`
+ <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
+ `;
+ }
+
+ private renderDate() {
+ if (!this.comment?.updated || this.collapsed) return;
+ return html`
+ <span class="separator"></span>
+ <span class="date" tabindex="0" @click="${this.handleAnchorClick}">
+ <gr-date-formatter
+ withTooltip
+ .dateStr="${this.comment.updated}"
+ ></gr-date-formatter>
+ </span>
+ `;
+ }
+
+ private renderToggle() {
+ const icon = this.collapsed
+ ? 'gr-icons:expand-more'
+ : 'gr-icons:expand-less';
+ const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
+ return html`
+ <div class="show-hide" tabindex="0">
+ <label class="show-hide" aria-label="${ariaLabel}">
+ <input
+ type="checkbox"
+ class="show-hide"
+ ?checked="${this.collapsed}"
+ @change="${() => (this.collapsed = !this.collapsed)}"
+ />
+ <iron-icon id="icon" icon="${icon}"></iron-icon>
+ </label>
+ </div>
+ `;
+ }
+
+ private renderRobotAuthor() {
+ if (!isRobot(this.comment) || this.collapsed) return;
+ return html`<div class="robotId">${this.comment.author?.name}</div>`;
+ }
+
+ private renderEditingTextarea() {
+ if (!this.editing || this.collapsed) return;
+ return html`
+ <gr-textarea
+ id="editTextarea"
+ class="editMessage"
+ autocomplete="on"
+ code=""
+ ?disabled="${this.saving}"
+ rows="4"
+ text="${this.messageText}"
+ @text-changed="${(e: ValueChangedEvent) => {
+ // TODO: This is causing a re-render of <gr-comment> on every key
+ // press. Try to avoid always setting `this.messageText` or at least
+ // debounce it. Most of the code can just inspect the current value
+ // of the textare instead of needing a dedicated property.
+ this.messageText = e.detail.value;
+ this.autoSaveTrigger$.next();
+ }}"
+ ></gr-textarea>
+ `;
+ }
+
+ private renderRespectfulTip() {
+ if (!this.showRespectfulTip || this.respectfulTipDismissed) return;
+ if (this.collapsed) return;
+ return html`
+ <div class="respectfulReviewTip">
+ <div>
+ <gr-tooltip-content
+ has-tooltip
+ title="Tips for respectful code reviews."
+ >
+ <iron-icon
+ class="pointer"
+ icon="gr-icons:lightbulb-outline"
+ ></iron-icon>
+ </gr-tooltip-content>
+ ${this.respectfulReviewTip}
+ </div>
+ <div>
+ <a
+ tabindex="-1"
+ @click="${this.onRespectfulReadMoreClick}"
+ href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
+ target="_blank"
+ >
+ Read more
+ </a>
+ <a
+ tabindex="-1"
+ class="close pointer"
+ @click="${this.dismissRespectfulTip}"
+ >
+ Not helpful
+ </a>
+ </div>
+ </div>
+ `;
+ }
+
+ private renderCommentMessage() {
+ if (this.collapsed || this.editing) return;
+ return html`
+ <!--The "message" class is needed to ensure selectability from
+ gr-diff-selection.-->
+ <gr-formatted-text
+ class="message"
+ .content="${this.comment?.message}"
+ .config="${this.commentLinks}"
+ ?noTrailingMargin="${!isDraftOrUnsaved(this.comment)}"
+ ></gr-formatted-text>
+ `;
+ }
+
+ private renderCopyLinkIcon() {
+ // Only show the icon when the thread contains a published comment.
+ if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+ return html`
+ <iron-icon
+ class="copy link-icon"
+ @click="${this.handleCopyLink}"
+ title="Copy link to this comment"
+ icon="gr-icons:link"
+ role="button"
+ tabindex="0"
+ >
+ </iron-icon>
+ `;
+ }
+
+ private renderHumanActions() {
+ if (!this.account || isRobot(this.comment)) return;
+ if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+ return html`
+ <div class="actions">
+ <div class="action resolve">
+ <label>
+ <input
+ type="checkbox"
+ id="resolvedCheckbox"
+ ?checked="${!this.unresolved}"
+ @change="${this.handleToggleResolved}"
+ />
+ Resolved
+ </label>
+ </div>
+ ${this.renderDraftActions()}
+ </div>
+ `;
+ }
+
+ private renderDraftActions() {
+ if (!isDraftOrUnsaved(this.comment)) return;
+ return html`
+ <div class="rightActions">
+ ${this.autoSaving ? html`. ` : ''}
+ ${this.renderCopyLinkIcon()} ${this.renderDiscardButton()}
+ ${this.renderEditButton()} ${this.renderCancelButton()}
+ ${this.renderSaveButton()}
+ </div>
+ `;
+ }
+
+ private renderDiscardButton() {
+ if (this.editing) return;
+ return html`<gr-button
+ link
+ ?disabled="${this.saving}"
+ class="action discard"
+ @click="${this.discard}"
+ >Discard</gr-button
+ >`;
+ }
+
+ private renderEditButton() {
+ if (this.editing) return;
+ return html`<gr-button
+ link
+ ?disabled="${this.saving}"
+ class="action edit"
+ @click="${this.edit}"
+ >Edit</gr-button
+ >`;
+ }
+
+ private renderCancelButton() {
+ if (!this.editing) return;
+ return html`
+ <gr-button
+ link
+ ?disabled="${this.saving}"
+ class="action cancel"
+ @click="${this.cancel}"
+ >Cancel</gr-button
+ >
+ `;
+ }
+
+ private renderSaveButton() {
+ if (!this.editing && !this.unableToSave) return;
+ return html`
+ <gr-button
+ link
+ ?disabled="${this.isSaveDisabled()}"
+ class="action save"
+ @click="${this.save}"
+ >Save</gr-button
+ >
+ `;
+ }
+
+ private renderRobotActions() {
+ if (!this.account || !isRobot(this.comment)) return;
+ const endpoint = html`
+ <gr-endpoint-decorator name="robot-comment-controls">
+ <gr-endpoint-param name="comment" .value="${this.comment}">
+ </gr-endpoint-param>
+ </gr-endpoint-decorator>
+ `;
+ return html`
+ <div class="robotActions">
+ ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
+ ${this.renderPleaseFixButton()}
+ </div>
+ `;
+ }
+
+ private renderShowFixButton() {
+ if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+ return html`
+ <gr-button
+ link
+ secondary
+ class="action show-fix"
+ ?disabled="${this.saving}"
+ @click="${this.handleShowFix}"
+ >
+ Show Fix
+ </gr-button>
+ `;
+ }
+
+ private renderPleaseFixButton() {
+ if (this.hasHumanReply()) return;
+ return html`
+ <gr-button
+ link
+ ?disabled="${this.robotButtonDisabled}"
+ class="action fix"
+ @click="${this.handleFix}"
+ >
+ Please Fix
+ </gr-button>
+ `;
+ }
+
+ private renderConfirmDialog() {
+ if (!this.showConfirmDeleteOverlay) return;
+ return html`
+ <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+ <gr-confirm-delete-comment-dialog
+ id="confirmDeleteComment"
+ @confirm="${this.handleConfirmDeleteComment}"
+ @cancel="${this.closeDeleteCommentOverlay}"
+ >
+ </gr-confirm-delete-comment-dialog>
+ </gr-overlay>
+ `;
+ }
+
+ private getUrlForComment() {
+ const comment = this.comment;
+ if (!comment || !this.changeNum || !this.repoName) return '';
if (!comment.id) throw new Error('comment must have an id');
return GerritNav.getUrlForComment(
this.changeNum as NumericChangeId,
- this.projectName,
+ this.repoName,
comment.id
);
}
- _handlePortedMessageClick() {
+ private firstWillUpdateDone = false;
+
+ firstWillUpdate() {
+ if (this.firstWillUpdateDone) return;
+ this.firstWillUpdateDone = true;
+
+ assertIsDefined(this.comment, 'comment');
+ this.unresolved = this.comment.unresolved ?? true;
+ if (isUnsaved(this.comment)) this.editing = true;
+ if (isDraftOrUnsaved(this.comment)) {
+ this.collapsed = false;
+ } else {
+ this.collapsed = !!this.initiallyCollapsed;
+ }
+ }
+
+ override willUpdate(changed: PropertyValues) {
+ this.firstWillUpdate();
+ if (changed.has('editing')) {
+ this.onEditingChanged();
+ }
+ if (changed.has('unresolved')) {
+ // The <gr-comment-thread> component wants to change its color based on
+ // the (dirty) unresolved state, so let's notify it about changes.
+ fire(this, 'comment-unresolved-changed', this.unresolved);
+ }
+ }
+
+ private handlePortedMessageClick() {
assertIsDefined(this.comment, 'comment');
this.reporting.reportInteraction('navigate-to-original-comment', {
line: this.comment.line,
@@ -348,753 +928,245 @@
});
}
- @observe('editing')
- _onEditingChange(editing?: boolean) {
- this.dispatchEvent(
- new CustomEvent('comment-editing-changed', {
- detail: !!editing,
- bubbles: true,
- composed: true,
- })
+ // private, but visible for testing
+ getRandomInt(from: number, to: number) {
+ return getRandomInt(from, to);
+ }
+
+ private dismissRespectfulTip() {
+ this.respectfulTipDismissed = true;
+ this.reporting.reportInteraction('respectful-tip-dismissed', {
+ tip: this.respectfulReviewTip,
+ });
+ // add a 14-day delay to the tip cache
+ this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+ }
+
+ private onRespectfulReadMoreClick() {
+ this.reporting.reportInteraction('respectful-read-more-clicked');
+ }
+
+ private handleCopyLink() {
+ fireEvent(this, 'copy-comment-link');
+ }
+
+ /** Enter editing mode. */
+ private edit() {
+ if (!isDraftOrUnsaved(this.comment)) {
+ throw new Error('Cannot edit published comment.');
+ }
+ if (this.editing) return;
+ this.editing = true;
+ }
+
+ // TODO: Move this out of gr-comment. gr-comment should not have a comments
+ // property.
+ private hasHumanReply() {
+ if (!this.comment || !this.comments) return false;
+ return this.comments.some(
+ c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
);
- if (!editing) return;
+ }
+
+ // private, but visible for testing
+ getEventPayload(): OpenFixPreviewEventDetail {
+ assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+ return {comment: this.comment, patchNum: this.comment.patch_set};
+ }
+
+ private onEditingChanged() {
+ if (this.editing) {
+ this.collapsed = false;
+ this.messageText = this.comment?.message ?? '';
+ this.unresolved = this.comment?.unresolved ?? true;
+ this.originalMessage = this.messageText;
+ this.originalUnresolved = this.unresolved;
+ setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
+ }
+ this.setRespectfulTip();
+
+ // Parent components such as the reply dialog might be interested in whether
+ // come of their child components are in editing mode.
+ fire(this, 'comment-editing-changed', this.editing);
+ }
+
+ private setRespectfulTip() {
// visibility based on cache this will make sure we only and always show
// a tip once every Math.max(a day, period between creating comments)
const cachedVisibilityOfRespectfulTip =
this.storage.getRespectfulTipVisibility();
- if (!cachedVisibilityOfRespectfulTip) {
- // we still want to show the tip with a probability of 30%
- if (this.getRandomNum(0, 3) >= 1) return;
- this._showRespectfulTip = true;
- const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
- this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
+ if (this.editing && !cachedVisibilityOfRespectfulTip) {
+ // we still want to show the tip with a probability of 33%
+ if (this.getRandomInt(0, 2) >= 1) return;
+ this.showRespectfulTip = true;
+ const randomIdx = this.getRandomInt(0, RESPECTFUL_REVIEW_TIPS.length);
+ this.respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
this.reporting.reportInteraction('respectful-tip-appeared', {
- tip: this._respectfulReviewTip,
+ tip: this.respectfulReviewTip,
});
// update cache
this.storage.setRespectfulTipVisibility();
}
}
- /** Set as a separate method so easy to stub. */
- getRandomNum(min: number, max: number) {
- return Math.floor(Math.random() * (max - min) + min);
+ // private, but visible for testing
+ isSaveDisabled() {
+ assertIsDefined(this.comment, 'comment');
+ if (this.saving) return true;
+ if (this.comment.unresolved !== this.unresolved) return false;
+ return !this.messageText?.trimEnd();
}
- _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
- return showTip && !tipDismissed;
+ private handleEsc() {
+ // vim users don't like ESC to cancel/discard, so only do this when the
+ // comment text is empty.
+ if (!this.messageText?.trimEnd()) this.cancel();
}
- _dismissRespectfulTip() {
- this._respectfulTipDismissed = true;
- this.reporting.reportInteraction('respectful-tip-dismissed', {
- tip: this._respectfulReviewTip,
+ private handleAnchorClick() {
+ assertIsDefined(this.comment, 'comment');
+ fire(this, 'comment-anchor-tap', {
+ number: this.comment.line || FILE,
+ side: this.comment?.side,
});
- // add a 14-day delay to the tip cache
- this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
}
- _onRespectfulReadMoreClick() {
- this.reporting.reportInteraction('respectful-read-more-clicked');
+ private handleFix() {
+ // Handled by <gr-comment-thread>.
+ fire(this, 'create-fix-comment', this.getEventPayload());
}
- get textarea(): GrTextarea | null {
- return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
+ private handleShowFix() {
+ // Handled top-level in the diff and change view components.
+ fire(this, 'open-fix-preview', this.getEventPayload());
}
- get confirmDeleteOverlay() {
- if (!this._overlays.confirmDelete) {
- this._enableOverlay = true;
- flush();
- this._overlays.confirmDelete = this.shadowRoot?.querySelector(
- '#confirmDeleteOverlay'
- ) as GrOverlay | null;
+ // private, but visible for testing
+ cancel() {
+ assertIsDefined(this.comment, 'comment');
+ if (!isDraftOrUnsaved(this.comment)) {
+ throw new Error('only unsaved and draft comments are editable');
}
- return this._overlays.confirmDelete;
+ this.messageText = this.originalMessage;
+ this.unresolved = this.originalUnresolved;
+ this.save();
}
- get confirmDiscardOverlay() {
- if (!this._overlays.confirmDiscard) {
- this._enableOverlay = true;
- flush();
- this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
- '#confirmDiscardOverlay'
- ) as GrOverlay | null;
+ async autoSave() {
+ if (this.saving || this.autoSaving) return;
+ if (!this.editing || !this.comment) return;
+ if (!isDraftOrUnsaved(this.comment)) return;
+ const messageToSave = this.messageText.trimEnd();
+ if (messageToSave === '') return;
+ if (messageToSave === this.comment.message) return;
+
+ try {
+ this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+ await this.autoSaving;
+ } finally {
+ this.autoSaving = undefined;
}
- return this._overlays.confirmDiscard;
}
- _computeShowHideIcon(collapsed: boolean) {
- return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+ async discard() {
+ this.messageText = '';
+ await this.save();
}
- _computeShowHideAriaLabel(collapsed: boolean) {
- return collapsed ? 'Expand' : 'Collapse';
- }
+ async save() {
+ if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
- @observe('showActions', 'isRobotComment')
- _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
- // Polymer 2: check for undefined
- if ([showActions, isRobotComment].includes(undefined)) {
- return;
- }
-
- this._showHumanActions = showActions && !isRobotComment;
- this._showRobotActions = showActions && isRobotComment;
- }
-
- hasPublishedComment(comments?: UIComment[]) {
- if (!comments?.length) return false;
- return comments.length > 1 || !isDraft(comments[0]);
- }
-
- @observe('comment')
- _isRobotComment(comment: UIRobot) {
- this.isRobotComment = !!comment.robot_id;
- }
-
- isOnParent() {
- return this.side === 'PARENT';
- }
-
- _getIsAdmin() {
- return this.restApiService.getIsAdmin();
- }
-
- _computeDraftTooltip(unableToSave: boolean) {
- return unableToSave
- ? 'Unable to save draft. Please try to save again.'
- : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
- "or 'Start review' button at the top of the change or press the 'A' key.";
- }
-
- _computeDraftText(unableToSave: boolean) {
- return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
- }
-
- handleCopyLink() {
- fireEvent(this, 'copy-comment-link');
- }
-
- save(opt_comment?: UIComment) {
- let comment = opt_comment;
- if (!comment) {
- comment = this.comment;
- }
-
- this.set('comment.message', this._messageText);
- this.editing = false;
- this.disabled = true;
-
- if (!this._messageText) {
- return this._discardDraft();
- }
-
- const details = this.commentDetailsForReporting();
- this.reporting.reportInteraction(Interaction.SAVE_COMMENT, details);
- this._xhrPromise = this._saveDraft(comment)
- .then(response => {
- this.disabled = false;
- if (!response.ok) {
- return;
+ try {
+ this.saving = true;
+ this.unableToSave = false;
+ if (this.autoSaving) await this.autoSaving;
+ // Depending on whether `messageToSave` is empty we treat this either as
+ // a discard or a save action.
+ const messageToSave = this.messageText.trimEnd();
+ if (messageToSave === '') {
+ // Don't try to discard UnsavedInfo. Nothing to do then.
+ if (this.comment.id) {
+ await this.commentsModel.discardDraft(this.comment.id);
}
-
- this._eraseDraftCommentFromStorage();
- return this.restApiService.getResponseObject(response).then(obj => {
- const resComment = obj as unknown as UIDraft;
- if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
- resComment.__draft = true;
- // Maintain the ephemeral draft ID for identification by other
- // elements.
- if (this.comment?.__draftID) {
- resComment.__draftID = this.comment.__draftID;
- }
- if (!resComment.patch_set) resComment.patch_set = this.patchNum;
- this.comment = resComment;
- const details = this.commentDetailsForReporting();
- this.reporting.reportInteraction(Interaction.COMMENT_SAVED, details);
- this._fireSave();
- return obj;
- });
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
-
- return this._xhrPromise;
- }
-
- private commentDetailsForReporting() {
- return {
- id: this.comment?.id,
- message_length: this.comment?.message?.length,
- in_reply_to: this.comment?.in_reply_to,
- unresolved: this.comment?.unresolved,
- path_length: this.comment?.path?.length,
- line: this.comment?.range?.start_line ?? this.comment?.line,
- };
- }
-
- _eraseDraftCommentFromStorage() {
- // Prevents a race condition in which removing the draft comment occurs
- // prior to it being saved.
- this.storeTask?.cancel();
-
- assertIsDefined(this.comment?.path, 'comment.path');
- assertIsDefined(this.changeNum, 'changeNum');
- this.storage.eraseDraftComment({
- changeNum: this.changeNum,
- patchNum: this._getPatchNum(),
- path: this.comment.path,
- line: this.comment.line,
- range: this.comment.range,
- });
- }
-
- _commentChanged(comment: UIComment) {
- this.editing = isDraft(comment) && !!comment.__editing;
- this.resolved = !comment.unresolved;
- this.discarding = false;
- if (this.editing) {
- // It's a new draft/reply, notify.
- this._fireUpdate();
- }
- }
-
- @observe('comment', 'comments.*')
- _computeHasHumanReply() {
- 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 === comment.id &&
- !(c as UIRobot).robot_id
- );
- }
-
- _getEventPayload(): OpenFixPreviewEventDetail {
- return {comment: this.comment, patchNum: this.patchNum};
- }
-
- _fireEdit() {
- if (this.comment) this.commentsService.editDraft(this.comment);
- this.dispatchEvent(
- new CustomEvent('comment-edit', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- }
-
- _fireSave() {
- if (this.comment) this.commentsService.addDraft(this.comment);
- this.dispatchEvent(
- new CustomEvent('comment-save', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- }
-
- _fireUpdate() {
- this.fireUpdateTask = debounce(this.fireUpdateTask, () => {
- this.dispatchEvent(
- new CustomEvent('comment-update', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- });
- }
-
- _computeAccountLabelClass(draft: boolean) {
- return draft ? 'draft' : '';
- }
-
- _draftChanged(draft: boolean) {
- this.$.container.classList.toggle('draft', draft);
- }
-
- _editingChanged(editing?: boolean, previousValue?: boolean) {
- // Polymer 2: observer fires when at least one property is defined.
- // Do nothing to prevent comment.__editing being overwritten
- // if previousValue is undefined
- if (previousValue === undefined) return;
-
- this.$.container.classList.toggle('editing', editing);
- if (this.comment && this.comment.id) {
- const cancelButton = this.shadowRoot?.querySelector(
- '.cancel'
- ) as GrButton | null;
- if (cancelButton) {
- cancelButton.hidden = !editing;
+ } else {
+ // No need to make a backend call when nothing has changed.
+ if (
+ messageToSave !== this.comment?.message ||
+ this.unresolved !== this.comment.unresolved
+ ) {
+ await this.rawSave(messageToSave, {showToast: true});
+ }
}
- }
- if (isDraft(this.comment)) {
- this.comment.__editing = this.editing;
- }
- if (!!editing !== !!previousValue) {
- // To prevent event firing on comment creation.
- this._fireUpdate();
- }
- if (editing) {
- setTimeout(() => {
- flush();
- this.textarea && this.textarea.putCursorAtEnd();
- }, 1);
- }
- }
-
- _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
- return isAdmin && !draft ? 'showDeleteButtons' : '';
- }
-
- _computeSaveDisabled(
- draft: string,
- comment: UIComment | undefined,
- resolved?: boolean
- ) {
- // If resolved state has changed and a msg exists, save should be enabled.
- if (!comment || (comment.unresolved === resolved && draft)) {
- return false;
- }
- return !draft || draft.trim() === '';
- }
-
- _handleSaveKey(e: Event) {
- if (
- !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
- ) {
- e.preventDefault();
- this._handleSave(e);
- }
- }
-
- _handleEsc() {
- if (!this._messageText.length) {
- this._handleCancel();
- }
- }
-
- _handleToggleCollapsed() {
- this.collapsed = !this.collapsed;
- }
-
- _toggleCollapseClass(collapsed: boolean) {
- if (collapsed) {
- this.$.container.classList.add('collapsed');
- } else {
- this.$.container.classList.remove('collapsed');
- }
- }
-
- @observe('comment.message')
- _commentMessageChanged(message: string) {
- /*
- * Only overwrite the message text user has typed if there is no existing
- * text typed by the user. This prevents the bug where creating another
- * comment triggered a recomputation of comments and the text written by
- * the user was lost.
- */
- if (!this._messageText || !this.editing) this._messageText = message || '';
- }
-
- _messageTextChanged(_: string, oldValue: string) {
- // Only store comments that are being edited in local storage.
- if (
- !this.comment ||
- (this.comment.id && (!isDraft(this.comment) || !this.comment.__editing))
- ) {
- return;
- }
-
- const patchNum = this.comment.patch_set
- ? this.comment.patch_set
- : this._getPatchNum();
- const {path, line, range} = this.comment;
- if (!path) return;
- this.storeTask = debounce(
- this.storeTask,
- () => {
- const message = this._messageText;
- if (this.changeNum === undefined) {
- throw new Error('undefined changeNum');
- }
- const commentLocation: StorageLocation = {
- changeNum: this.changeNum,
- patchNum,
- path,
- line,
- range,
- };
-
- 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) return;
- this.dispatchEvent(
- new CustomEvent('comment-anchor-tap', {
- bubbles: true,
- composed: true,
- detail: {
- number: this.comment.line || FILE,
- side: this.side,
- },
- })
- );
- }
-
- _handleEdit(e: Event) {
- e.preventDefault();
- if (this.comment?.message) this._messageText = this.comment.message;
- this.editing = true;
- this._fireEdit();
- this.reporting.recordDraftInteraction();
- }
-
- _handleSave(e: Event) {
- e.preventDefault();
-
- // Ignore saves started while already saving.
- if (this.disabled) return;
- const timingLabel = this.comment?.id
- ? REPORT_UPDATE_DRAFT
- : REPORT_CREATE_DRAFT;
- const timer = this.reporting.getTimer(timingLabel);
- this.set('comment.__editing', false);
- return this.save().then(() => {
- timer.end({id: this.comment?.id});
- });
- }
-
- _handleCancel() {
- if (!this.comment) return;
- if (!this.comment.id) {
- // Ensures we update the discarded draft message before deleting the draft
- this.set('comment.message', this._messageText);
- this._fireDiscard();
- } else {
- this.set('comment.__editing', false);
- this.commentsService.cancelDraft(this.comment);
this.editing = false;
+ } catch (e) {
+ this.unableToSave = true;
+ throw e;
+ } finally {
+ this.saving = false;
}
}
- _fireDiscard() {
- if (this.comment) this.commentsService.deleteDraft(this.comment);
- this.fireUpdateTask?.cancel();
- this.dispatchEvent(
- new CustomEvent('comment-discard', {
- detail: this._getEventPayload(),
- composed: true,
- bubbles: true,
- })
- );
- }
-
- _handleFix() {
- this.dispatchEvent(
- new CustomEvent('create-fix-comment', {
- bubbles: true,
- composed: true,
- detail: this._getEventPayload(),
- })
- );
- }
-
- _handleShowFix() {
- this.dispatchEvent(
- new CustomEvent('open-fix-preview', {
- bubbles: true,
- composed: true,
- detail: this._getEventPayload(),
- })
- );
- }
-
- _hasNoFix(comment?: UIComment) {
- return !comment || !(comment as UIRobot).fix_suggestions;
- }
-
- _handleDiscard(e: Event) {
- e.preventDefault();
- this.reporting.recordDraftInteraction();
-
- this._discardDraft();
- }
-
- _discardDraft() {
- if (!this.comment) return Promise.reject(new Error('undefined comment'));
- if (!isDraft(this.comment)) {
- return Promise.reject(new Error('Cannot discard a non-draft comment.'));
- }
- this.discarding = true;
- const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
- this.editing = false;
- this.disabled = true;
- this._eraseDraftCommentFromStorage();
-
- if (!this.comment.id) {
- this.disabled = false;
- this._fireDiscard();
- return Promise.resolve();
- }
-
- this._xhrPromise = this._deleteDraft(this.comment)
- .then(response => {
- this.disabled = false;
- if (!response.ok) {
- this.discarding = false;
- }
- timer.end({id: this.comment?.id});
- this._fireDiscard();
- return response;
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
-
- return this._xhrPromise;
- }
-
- _getSavingMessage(numPending: number, requestFailed?: boolean) {
- if (requestFailed) {
- return UNSAVED_MESSAGE;
- }
- if (numPending === 0) {
- return SAVED_MESSAGE;
- }
- return `Saving ${pluralize(numPending, 'draft')}...`;
- }
-
- _showStartRequest() {
- const numPending = ++this._numPendingDraftRequests.number;
- this._updateRequestToast(numPending);
- }
-
- _showEndRequest() {
- const numPending = --this._numPendingDraftRequests.number;
- this._updateRequestToast(numPending);
- }
-
- _handleFailedDraftRequest() {
- this._numPendingDraftRequests.number--;
-
- // Cancel the debouncer so that error toasts from the error-manager will
- // not be overridden.
- this.draftToastTask?.cancel();
- this._updateRequestToast(
- this._numPendingDraftRequests.number,
- /* requestFailed=*/ true
- );
- }
-
- _updateRequestToast(numPending: number, requestFailed?: boolean) {
- const message = this._getSavingMessage(numPending, requestFailed);
- this.draftToastTask = debounce(
- this.draftToastTask,
- () => {
- // Note: the event is fired on the body rather than this element because
- // this element may not be attached by the time this executes, in which
- // case the event would not bubble.
- fireAlert(document.body, message);
+ /** For sharing between save() and autoSave(). */
+ private rawSave(message: string, options: {showToast: boolean}) {
+ if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+ return this.commentsModel.saveDraft(
+ {
+ ...this.comment,
+ message,
+ unresolved: this.unresolved,
},
- TOAST_DEBOUNCE_INTERVAL
+ options.showToast
);
}
- _handleDraftFailure() {
- this.$.container.classList.add('unableToSave');
- this._unableToSave = true;
- this._handleFailedDraftRequest();
+ private handleToggleResolved() {
+ this.unresolved = !this.unresolved;
+ if (!this.editing) this.save();
}
- _saveDraft(draft?: UIComment) {
- if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
- throw new Error('undefined draft or changeNum or patchNum');
- }
- this._showStartRequest();
- return this.restApiService
- .saveDiffDraft(this.changeNum, this.patchNum, draft)
- .then(result => {
- if (result.ok) {
- // remove
- this._unableToSave = false;
- this.$.container.classList.remove('unableToSave');
- this._showEndRequest();
- } else {
- this._handleDraftFailure();
- }
- return result;
- })
- .catch(err => {
- this._handleDraftFailure();
- throw err;
- });
+ private async openDeleteCommentOverlay() {
+ this.showConfirmDeleteOverlay = true;
+ await this.updateComplete;
+ await this.confirmDeleteOverlay?.open();
}
- _deleteDraft(draft: UIComment) {
- const changeNum = this.changeNum;
- const patchNum = this.patchNum;
- if (changeNum === undefined || patchNum === undefined) {
- throw new Error('undefined changeNum or patchNum');
- }
- fireAlert(this, 'Discarding draft...');
- const draftID = draft.id;
- if (!draftID) throw new Error('Missing id in comment draft.');
- return this.restApiService
- .deleteDiffDraft(changeNum, patchNum, {id: draftID})
- .then(result => {
- if (result.ok) {
- fire(this, 'show-alert', {
- message: 'Draft Discarded',
- action: 'Undo',
- callback: () =>
- this.commentsService.restoreDraft(changeNum, patchNum, draftID),
- });
- }
- return result;
- });
+ private closeDeleteCommentOverlay() {
+ this.showConfirmDeleteOverlay = false;
+ this.confirmDeleteOverlay?.remove();
+ this.confirmDeleteOverlay?.close();
}
- _getPatchNum(): PatchSetNum {
- const patchNum = this.isOnParent()
- ? ('PARENT' as BasePatchSetNum)
- : this.patchNum;
- if (patchNum === undefined) throw new Error('patchNum undefined');
- return patchNum;
- }
-
- @observe('changeNum', 'patchNum', 'comment')
- _loadLocalDraft(
- changeNum: number,
- patchNum?: PatchSetNum,
- comment?: UIComment
- ) {
- // Polymer 2: check for undefined
- if ([changeNum, patchNum, comment].includes(undefined)) {
- return;
- }
-
- // Only apply local drafts to comments that are drafts and are currently
- // being edited.
- if (
- !comment ||
- !comment.path ||
- comment.message ||
- !isDraft(comment) ||
- !comment.__editing
- ) {
- return;
- }
-
- const draft = this.storage.getDraftComment({
- changeNum,
- patchNum: this._getPatchNum(),
- path: comment.path,
- line: comment.line,
- range: comment.range,
- });
-
- if (draft) {
- this._messageText = draft.message || '';
- }
- }
-
- _handleToggleResolved() {
- this.reporting.recordDraftInteraction();
- this.resolved = !this.resolved;
- // 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', {
- detail: payload,
- composed: true,
- bubbles: true,
- })
- );
- if (!this.editing) {
- // Save the resolved state immediately.
- this.save(payload.comment);
- }
- }
-
- _handleCommentDelete() {
- this._openOverlay(this.confirmDeleteOverlay);
- }
-
- _handleCancelDeleteComment() {
- this._closeOverlay(this.confirmDeleteOverlay);
- }
-
- _openOverlay(overlay?: GrOverlay | null) {
- if (!overlay) {
- return Promise.reject(new Error('undefined overlay'));
- }
- getRootElement().appendChild(overlay);
- return overlay.open();
- }
-
- _computeHideRunDetails(comment: UIComment | undefined, collapsed: boolean) {
- if (!comment) return true;
- if (!isRobot(comment)) return true;
- return !comment.url || collapsed;
- }
-
- _closeOverlay(overlay?: GrOverlay | null) {
- if (overlay) {
- getRootElement().removeChild(overlay);
- overlay.close();
- }
- }
-
- _handleConfirmDeleteComment() {
+ /**
+ * Deleting a *published* comment is an admin feature. It means more than just
+ * discarding a draft.
+ *
+ * TODO: Also move this into the comments-service.
+ * TODO: Figure out a good reloading strategy when deleting was successful.
+ * `this.comment = newComment` does not seem sufficient.
+ */
+ // private, but visible for testing
+ handleConfirmDeleteComment() {
const dialog = this.confirmDeleteOverlay?.querySelector(
'#confirmDeleteComment'
) as GrConfirmDeleteCommentDialog | null;
if (!dialog || !dialog.message) {
throw new Error('missing confirm delete dialog');
}
- if (
- !this.comment ||
- !this.comment.id ||
- this.changeNum === undefined ||
- this.patchNum === undefined
- ) {
- throw new Error('undefined comment or id or changeNum or patchNum');
+ assertIsDefined(this.changeNum, 'changeNum');
+ assertIsDefined(this.comment, 'comment');
+ assertIsDefined(this.comment.patch_set, 'comment.patch_set');
+ if (isDraftOrUnsaved(this.comment)) {
+ throw new Error('Admin deletion is only for published comments.');
}
this.restApiService
.deleteComment(
this.changeNum,
- this.patchNum,
+ this.comment.patch_set,
this.comment.id,
dialog.message
)
.then(newComment => {
- this._handleCancelDeleteComment();
+ this.closeDeleteCommentOverlay();
this.comment = newComment;
});
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
deleted file mode 100644
index b77c4b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- :host {
- display: block;
- font-family: var(--font-family);
- padding: var(--spacing-m);
- }
- :host([collapsed]) {
- padding: var(--spacing-s) var(--spacing-m);
- }
- :host([disabled]) {
- pointer-events: none;
- }
- :host([disabled]) .actions,
- :host([disabled]) .robotActions,
- :host([disabled]) .date {
- opacity: 0.5;
- }
- :host([discarding]) {
- display: none;
- }
- .body {
- padding-top: var(--spacing-m);
- }
- .header {
- align-items: center;
- cursor: pointer;
- display: flex;
- }
- .headerLeft > span {
- font-weight: var(--font-weight-bold);
- }
- .headerMiddle {
- color: var(--deemphasized-text-color);
- flex: 1;
- overflow: hidden;
- }
- .draftLabel,
- .draftTooltip {
- color: var(--deemphasized-text-color);
- display: none;
- }
- .date {
- justify-content: flex-end;
- text-align: right;
- white-space: nowrap;
- }
- span.date {
- color: var(--deemphasized-text-color);
- }
- span.date:hover {
- text-decoration: underline;
- }
- .actions,
- .robotActions {
- display: flex;
- justify-content: flex-end;
- padding-top: 0;
- }
- .robotActions {
- /* Better than the negative margin would be to remove the gr-button
- * padding, but then we would also need to fix the buttons that are
- * inserted by plugins. :-/ */
- margin: 4px 0 -4px;
- }
- .action {
- margin-left: var(--spacing-l);
- }
- .rightActions {
- display: flex;
- justify-content: flex-end;
- }
- .rightActions gr-button {
- --gr-button-padding: 0 var(--spacing-s);
- }
- .editMessage {
- display: none;
- margin: var(--spacing-m) 0;
- width: 100%;
- }
- .container:not(.draft) .actions .hideOnPublished {
- display: none;
- }
- .draft .reply,
- .draft .quote,
- .draft .ack,
- .draft .done {
- display: none;
- }
- .draft .draftLabel,
- .draft .draftTooltip {
- display: inline;
- }
- .draft:not(.editing):not(.unableToSave) .save,
- .draft:not(.editing) .cancel {
- display: none;
- }
- .editing .message,
- .editing .reply,
- .editing .quote,
- .editing .ack,
- .editing .done,
- .editing .edit,
- .editing .discard,
- .editing .unresolved {
- display: none;
- }
- .editing .editMessage {
- display: block;
- }
- .show-hide {
- margin-left: var(--spacing-s);
- }
- .robotId {
- color: var(--deemphasized-text-color);
- margin-bottom: var(--spacing-m);
- }
- .robotRun {
- margin-left: var(--spacing-m);
- }
- .robotRunLink {
- margin-left: var(--spacing-m);
- }
- input.show-hide {
- display: none;
- }
- label.show-hide {
- cursor: pointer;
- display: block;
- }
- label.show-hide iron-icon {
- vertical-align: top;
- }
- #container .collapsedContent {
- display: none;
- }
- #container.collapsed .body {
- padding-top: 0;
- }
- #container.collapsed .collapsedContent {
- display: block;
- overflow: hidden;
- padding-left: var(--spacing-m);
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- #container.collapsed #deleteBtn,
- #container.collapsed .date,
- #container.collapsed .actions,
- #container.collapsed gr-formatted-text,
- #container.collapsed gr-textarea,
- #container.collapsed .respectfulReviewTip {
- display: none;
- }
- .resolve,
- .unresolved {
- align-items: center;
- display: flex;
- flex: 1;
- margin: 0;
- }
- .resolve label {
- color: var(--comment-text-color);
- }
- gr-dialog .main {
- display: flex;
- flex-direction: column;
- width: 100%;
- }
- #deleteBtn {
- display: none;
- --gr-button-text-color: var(--deemphasized-text-color);
- --gr-button-padding: 0;
- }
- #deleteBtn.showDeleteButtons {
- display: block;
- }
-
- /** Disable select for the caret and actions */
- .actions,
- .show-hide {
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- }
-
- .respectfulReviewTip {
- justify-content: space-between;
- display: flex;
- padding: var(--spacing-m);
- border: 1px solid var(--border-color);
- border-radius: var(--border-radius);
- margin-bottom: var(--spacing-m);
- }
- .respectfulReviewTip div {
- display: flex;
- }
- .respectfulReviewTip div iron-icon {
- margin-right: var(--spacing-s);
- }
- .respectfulReviewTip a {
- white-space: nowrap;
- margin-right: var(--spacing-s);
- padding-left: var(--spacing-m);
- text-decoration: none;
- }
- .pointer {
- cursor: pointer;
- }
- .patchset-text {
- color: var(--deemphasized-text-color);
- margin-left: var(--spacing-s);
- }
- .headerLeft gr-account-label {
- --account-max-length: 130px;
- width: 150px;
- }
- .headerLeft gr-account-label::part(gr-account-label-text) {
- font-weight: var(--font-weight-bold);
- }
- .draft gr-account-label {
- width: unset;
- }
- .portedMessage {
- margin: 0 var(--spacing-m);
- }
- .link-icon {
- cursor: pointer;
- }
- </style>
- <div id="container" class="container">
- <div class="header" id="header" on-click="_handleToggleCollapsed">
- <div class="headerLeft">
- <template is="dom-if" if="[[comment.robot_id]]">
- <span class="robotName"> [[comment.robot_id]] </span>
- </template>
- <template is="dom-if" if="[[!comment.robot_id]]">
- <gr-account-label
- account="[[_getAuthor(comment, _selfAccount)]]"
- class$="[[_computeAccountLabelClass(draft)]]"
- hideStatus
- >
- </gr-account-label>
- </template>
- <template is="dom-if" if="[[showPortedComment]]">
- <a href="[[_getUrlForComment(comment)]]"
- ><span class="portedMessage" on-click="_handlePortedMessageClick"
- >From patchset [[comment.patch_set]]</span
- ></a
- >
- </template>
- <gr-tooltip-content
- class="draftTooltip"
- has-tooltip
- title="[[_computeDraftTooltip(_unableToSave)]]"
- max-width="20em"
- show-icon
- >
- <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
- </gr-tooltip-content>
- </div>
- <div class="headerMiddle">
- <span class="collapsedContent">[[comment.message]]</span>
- </div>
- <div
- hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
- class="runIdMessage message"
- >
- <div class="runIdInformation">
- <a class="robotRunLink" href$="[[comment.url]]">
- <span class="robotRun link">Run Details</span>
- </a>
- </div>
- </div>
- <gr-button
- id="deleteBtn"
- title="Delete Comment"
- link=""
- class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
- hidden$="[[isRobotComment]]"
- on-click="_handleCommentDelete"
- >
- <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
- </gr-button>
- <template is="dom-if" if="[[showPatchset]]">
- <span class="patchset-text"> Patchset [[patchNum]]</span>
- </template>
- <span class="separator"></span>
- <template is="dom-if" if="[[comment.updated]]">
- <span class="date" tabindex="0" on-click="_handleAnchorClick">
- <gr-date-formatter
- withTooltip
- date-str="[[comment.updated]]"
- ></gr-date-formatter>
- </span>
- </template>
- <div class="show-hide" tabindex="0">
- <label
- class="show-hide"
- aria-label$="[[_computeShowHideAriaLabel(collapsed)]]"
- >
- <input
- type="checkbox"
- class="show-hide"
- checked$="[[collapsed]]"
- on-change="_handleToggleCollapsed"
- />
- <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
- </iron-icon>
- </label>
- </div>
- </div>
- <div class="body">
- <template is="dom-if" if="[[isRobotComment]]">
- <div class="robotId" hidden$="[[collapsed]]">
- [[comment.author.name]]
- </div>
- </template>
- <template is="dom-if" if="[[editing]]">
- <gr-textarea
- id="editTextarea"
- class="editMessage"
- autocomplete="on"
- code=""
- disabled="{{disabled}}"
- rows="4"
- text="{{_messageText}}"
- ></gr-textarea>
- <template
- is="dom-if"
- if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
- >
- <div class="respectfulReviewTip">
- <div>
- <gr-tooltip-content
- has-tooltip
- title="Tips for respectful code reviews."
- >
- <iron-icon
- class="pointer"
- icon="gr-icons:lightbulb-outline"
- ></iron-icon>
- </gr-tooltip-content>
- [[_respectfulReviewTip]]
- </div>
- <div>
- <a
- tabindex="-1"
- on-click="_onRespectfulReadMoreClick"
- href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
- target="_blank"
- >
- Read more
- </a>
- <a
- tabindex="-1"
- class="close pointer"
- on-click="_dismissRespectfulTip"
- >Not helpful</a
- >
- </div>
- </div>
- </template>
- </template>
- <!--The message class is needed to ensure selectability from
- gr-diff-selection.-->
- <gr-formatted-text
- class="message"
- content="[[comment.message]]"
- no-trailing-margin="[[!comment.__draft]]"
- config="[[projectConfig.commentlinks]]"
- ></gr-formatted-text>
- <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
- <div class="action resolve hideOnPublished">
- <label>
- <input
- type="checkbox"
- id="resolvedCheckbox"
- checked="[[resolved]]"
- on-change="_handleToggleResolved"
- />
- Resolved
- </label>
- </div>
- <template is="dom-if" if="[[draft]]">
- <div class="rightActions">
- <template is="dom-if" if="[[hasPublishedComment(comments)]]">
- <iron-icon
- class="link-icon"
- on-click="handleCopyLink"
- class="copy"
- title="Copy link to this comment"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- >
- </iron-icon>
- </template>
- <gr-button
- link=""
- class="action cancel hideOnPublished"
- on-click="_handleCancel"
- >Cancel</gr-button
- >
- <gr-button
- link=""
- class="action discard hideOnPublished"
- on-click="_handleDiscard"
- >Discard</gr-button
- >
- <gr-button
- link=""
- class="action edit hideOnPublished"
- on-click="_handleEdit"
- >Edit</gr-button
- >
- <gr-button
- link=""
- disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
- class="action save hideOnPublished"
- on-click="_handleSave"
- >Save</gr-button
- >
- </div>
- </template>
- </div>
- <div class="robotActions" hidden$="[[!_showRobotActions]]">
- <template is="dom-if" if="[[hasPublishedComment(comments)]]">
- <iron-icon
- class="link-icon"
- on-click="handleCopyLink"
- class="copy"
- title="Copy link to this comment"
- icon="gr-icons:link"
- role="button"
- tabindex="0"
- >
- </iron-icon>
- </template>
- <template is="dom-if" if="[[isRobotComment]]">
- <gr-endpoint-decorator name="robot-comment-controls">
- <gr-endpoint-param name="comment" value="[[comment]]">
- </gr-endpoint-param>
- </gr-endpoint-decorator>
- <gr-button
- link=""
- secondary=""
- class="action show-fix"
- hidden$="[[_hasNoFix(comment)]]"
- on-click="_handleShowFix"
- >
- Show Fix
- </gr-button>
- <template is="dom-if" if="[[!_hasHumanReply]]">
- <gr-button
- link=""
- class="action fix"
- on-click="_handleFix"
- disabled="[[robotButtonDisabled]]"
- >
- Please Fix
- </gr-button>
- </template>
- </template>
- </div>
- </div>
- </div>
- <template is="dom-if" if="[[_enableOverlay]]">
- <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
- <gr-confirm-delete-comment-dialog
- id="confirmDeleteComment"
- on-confirm="_handleConfirmDeleteComment"
- on-cancel="_handleCancelDeleteComment"
- >
- </gr-confirm-delete-comment-dialog>
- </gr-overlay>
- </template>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index e0bda4a..28a52dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -14,1628 +14,663 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import '../../../test/common-test-setup-karma';
import './gr-comment';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
-import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {AUTO_SAVE_DEBOUNCE_DELAY_MS, GrComment} from './gr-comment';
import {
queryAndAssert,
stubRestApi,
stubStorage,
- spyStorage,
query,
- isVisible,
- stubReporting,
+ pressKey,
+ listenOnce,
+ stubComments,
mockPromise,
+ waitUntilCalled,
+ dispatch,
+ MockPromise,
} from '../../../test/test-utils';
import {
AccountId,
EmailAddress,
- FixId,
NumericChangeId,
- ParsedJSON,
PatchSetNum,
- RobotId,
- RobotRunId,
Timestamp,
UrlEncodedCommentId,
} from '../../../types/common';
-import {
- pressAndReleaseKeyOn,
- tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
import {
createComment,
createDraft,
createFixSuggestionInfo,
+ createRobotComment,
} from '../../../test/test-data-generators';
-import {Timer} from '../../../services/gr-reporting/gr-reporting';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {CreateFixCommentEvent} from '../../../types/events';
-import {DraftInfo, UIRobot} from '../../../utils/comment-util';
-import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {
+ CreateFixCommentEvent,
+ OpenFixPreviewEventDetail,
+} from '../../../types/events';
import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
- <gr-comment draft="true"></gr-comment>
-`);
+import {DraftInfo} from '../../../utils/comment-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Modifier} from '../../../utils/dom-util';
+import {SinonStub} from 'sinon';
suite('gr-comment tests', () => {
- suite('basic tests', () => {
- let element: GrComment;
+ let element: GrComment;
- let openOverlaySpy: sinon.SinonSpy;
+ setup(() => {
+ element = fixtureFromElement('gr-comment').instantiate();
+ element.account = {
+ email: 'dhruvsri@google.com' as EmailAddress,
+ name: 'Dhruv Srivastava',
+ _account_id: 1083225 as AccountId,
+ avatars: [{url: 'abc', height: 32, width: 32}],
+ registered_on: '123' as Timestamp,
+ };
+ element.showPatchset = true;
+ element.getRandomInt = () => 1;
+ element.comment = {
+ ...createComment(),
+ author: {
+ name: 'Mr. Peanutbutter',
+ email: 'tenn1sballchaser@aol.com' as EmailAddress,
+ },
+ id: 'baf0414d_60047215' as UrlEncodedCommentId,
+ line: 5,
+ message: 'This is the test comment message.',
+ updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+ };
+ });
- setup(() => {
- stubRestApi('getAccount').returns(
- Promise.resolve({
- email: 'dhruvsri@google.com' as EmailAddress,
- name: 'Dhruv Srivastava',
- _account_id: 1083225 as AccountId,
- avatars: [{url: 'abc', height: 32, width: 32}],
- registered_on: '123' as Timestamp,
- })
- );
- element = basicFixture.instantiate();
- element.getRandomNum = () => 1;
- element.comment = {
- ...createComment(),
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- id: 'baf0414d_60047215' as UrlEncodedCommentId,
- line: 5,
- message: 'is this a crossover episode!?',
- updated: '2015-12-08 19:48:33.843000000' as Timestamp,
- };
-
- openOverlaySpy = sinon.spy(element, '_openOverlay');
- });
-
- teardown(() => {
- openOverlaySpy.getCalls().forEach(call => {
- call.args[0].remove();
- });
- });
-
- test('renders', async () => {
- await flush();
+ suite('DOM rendering', () => {
+ test('renders collapsed', async () => {
+ element.initiallyCollapsed = true;
+ await element.updateComplete;
expect(element).shadowDom.to.equal(`
- <div class="collapsed container" id="container">
+ <div class="container" id="container">
<div class="header" id="header">
<div class="headerLeft">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
<gr-account-label deselected="" hidestatus=""></gr-account-label>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-tooltip-content
- class="draftTooltip"
- has-tooltip=""
- max-width="20em"
- show-icon=""
- title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
- >
- <span class="draftLabel">DRAFT</span>
- </gr-tooltip-content>
</div>
<div class="headerMiddle">
<span class="collapsedContent">
- is this a crossover episode!?
+ This is the test comment message.
</span>
</div>
- <div class="message runIdMessage" hidden="true">
- <div class="runIdInformation">
- <a class="robotRunLink">
- <span class="link robotRun">
- Run Details
- </span>
- </a>
- </div>
+ <span class="patchset-text">Patchset 1</span>
+ <div class="show-hide" tabindex="0">
+ <label aria-label="Expand" class="show-hide">
+ <input checked="" class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-more"></iron-icon>
+ </label>
</div>
- <gr-button
- aria-disabled="false"
- class="action delete"
- id="deleteBtn"
- link=""
- role="button"
- tabindex="0"
- title="Delete Comment"
- >
- <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
- </gr-button>
- <span class="patchset-text">Patchset</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ </div>
+ <div class="body"></div>
+ </div>
+ `);
+ });
+
+ test('renders expanded', async () => {
+ element.initiallyCollapsed = false;
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="container" id="container">
+ <div class="header" id="header">
+ <div class="headerLeft">
+ <gr-account-label deselected="" hidestatus=""></gr-account-label>
+ </div>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
<span class="separator"></span>
<span class="date" tabindex="0">
<gr-date-formatter withtooltip=""></gr-date-formatter>
</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
<div class="show-hide" tabindex="0">
- <label aria-label="Expand" class="show-hide">
- <input checked="true" class="show-hide" type="checkbox">
- <iron-icon id="icon"></iron-icon>
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
</label>
</div>
</div>
<div class="body">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-formatted-text class="message" notrailingmargin="">
- </gr-formatted-text>
- <div class="actions humanActions">
- <div class="action hideOnPublished resolve">
- <label>
- <input id="resolvedCheckbox" type="checkbox">
- Resolved
- </label>
- </div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- </div>
- <div class="robotActions">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- </div>
+ <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
</div>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
`);
});
- test('renders editing:true', async () => {
+ test('renders expanded robot', async () => {
+ element.initiallyCollapsed = false;
+ element.comment = createRobotComment();
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="container" id="container">
+ <div class="header" id="header">
+ <div class="headerLeft">
+ <span class="robotName">robot-id-123</span>
+ </div>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
+ <span class="separator"></span>
+ <span class="date" tabindex="0">
+ <gr-date-formatter withtooltip=""></gr-date-formatter>
+ </span>
+ <div class="show-hide" tabindex="0">
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+ </label>
+ </div>
+ </div>
+ <div class="body">
+ <div class="robotId"></div>
+ <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
+ <div class="robotActions">
+ <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0"
+ title="Copy link to this comment">
+ </iron-icon>
+ <gr-endpoint-decorator name="robot-comment-controls">
+ <gr-endpoint-param name="comment"></gr-endpoint-param>
+ </gr-endpoint-decorator>
+ <gr-button aria-disabled="false" class="action show-fix" link="" role="button" secondary="" tabindex="0">
+ Show Fix
+ </gr-button>
+ <gr-button aria-disabled="false" class="action fix" link="" role="button" tabindex="0">
+ Please Fix
+ </gr-button>
+ </div>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders expanded admin', async () => {
+ element.initiallyCollapsed = false;
+ element.isAdmin = true;
+ await element.updateComplete;
+ expect(queryAndAssert(element, 'gr-button.delete')).dom.to.equal(`
+ <gr-button
+ aria-disabled="false"
+ class="action delete"
+ id="deleteBtn"
+ link=""
+ role="button"
+ tabindex="0"
+ title="Delete Comment"
+ >
+ <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
+ </gr-button>
+ `);
+ });
+
+ test('renders draft', async () => {
+ element.initiallyCollapsed = false;
+ (element.comment as DraftInfo).__draft = true;
+ await element.updateComplete;
+ expect(element).shadowDom.to.equal(`
+ <div class="container draft" id="container">
+ <div class="header" id="header">
+ <div class="headerLeft">
+ <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
+ <gr-tooltip-content
+ class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+ title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+ >
+ <span class="draftLabel">DRAFT</span>
+ </gr-tooltip-content>
+ </div>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
+ <span class="separator"></span>
+ <span class="date" tabindex="0">
+ <gr-date-formatter withtooltip=""></gr-date-formatter>
+ </span>
+ <div class="show-hide" tabindex="0">
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+ </label>
+ </div>
+ </div>
+ <div class="body">
+ <gr-formatted-text class="message"></gr-formatted-text>
+ <div class="actions">
+ <div class="action resolve">
+ <label>
+ <input checked="" id="resolvedCheckbox" type="checkbox">
+ Resolved
+ </label>
+ </div>
+ <div class="rightActions">
+ <gr-button aria-disabled="false" class="action discard" link="" role="button" tabindex="0">
+ Discard
+ </gr-button>
+ <gr-button aria-disabled="false" class="action edit" link="" role="button" tabindex="0">
+ Edit
+ </gr-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ `);
+ });
+
+ test('renders draft in editing mode', async () => {
+ element.initiallyCollapsed = false;
+ (element.comment as DraftInfo).__draft = true;
element.editing = true;
- await flush();
+ await element.updateComplete;
expect(element).shadowDom.to.equal(`
- <div class="collapsed container editing" id="container">
+ <div class="container draft" id="container">
<div class="header" id="header">
<div class="headerLeft">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-account-label deselected="" hidestatus=""></gr-account-label>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
<gr-tooltip-content
- class="draftTooltip"
- has-tooltip=""
- max-width="20em"
- show-icon=""
- title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
+ class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+ title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
>
<span class="draftLabel">DRAFT</span>
</gr-tooltip-content>
</div>
- <div class="headerMiddle">
- <span class="collapsedContent">
- is this a crossover episode!?
- </span>
- </div>
- <div class="message runIdMessage" hidden="true">
- <div class="runIdInformation">
- <a class="robotRunLink">
- <span class="link robotRun">
- Run Details
- </span>
- </a>
- </div>
- </div>
- <gr-button
- aria-disabled="false"
- class="action delete"
- id="deleteBtn"
- link=""
- role="button"
- tabindex="0"
- title="Delete Comment"
- >
- <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
- </gr-button>
- <span class="patchset-text">Patchset</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ <div class="headerMiddle"></div>
+ <span class="patchset-text">Patchset 1</span>
<span class="separator"></span>
<span class="date" tabindex="0">
<gr-date-formatter withtooltip=""></gr-date-formatter>
</span>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
<div class="show-hide" tabindex="0">
- <label aria-label="Expand" class="show-hide">
- <input checked="true" class="show-hide" type="checkbox">
- <iron-icon id="icon"></iron-icon>
+ <label aria-label="Collapse" class="show-hide">
+ <input class="show-hide" type="checkbox">
+ <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
</label>
</div>
</div>
<div class="body">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-textarea autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4">
+ <gr-textarea
+ autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4"
+ text="This is the test comment message."
+ >
</gr-textarea>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <gr-formatted-text class="message" notrailingmargin="">
- </gr-formatted-text>
- <div class="actions humanActions">
- <div class="action hideOnPublished resolve">
+ <div class="actions">
+ <div class="action resolve">
<label>
- <input id="resolvedCheckbox" type="checkbox">
+ <input checked="" id="resolvedCheckbox" type="checkbox">
Resolved
</label>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- </div>
- <div class="robotActions">
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+ <div class="rightActions">
+ <gr-button aria-disabled="false" class="action cancel" link="" role="button" tabindex="0">
+ Cancel
+ </gr-button>
+ <gr-button aria-disabled="false" class="action save" link="" role="button" tabindex="0">
+ Save
+ </gr-button>
+ </div>
</div>
</div>
</div>
- <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
`);
});
-
- test('clicking on date link fires event', () => {
- element.side = 'PARENT';
- const stub = sinon.stub();
- element.addEventListener('comment-anchor-tap', stub);
- flush();
- const dateEl = queryAndAssert(element, '.date');
- assert.ok(dateEl);
- tap(dateEl);
-
- assert.isTrue(stub.called);
- assert.deepEqual(stub.lastCall.args[0].detail, {
- side: element.side,
- number: element.comment!.line,
- });
- });
-
- test('message is not retrieved from storage when missing path', async () => {
- const storageStub = stubStorage('getDraftComment');
- const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- };
- await flush();
- assert.isTrue(loadSpy.called);
- assert.isFalse(storageStub.called);
- });
-
- test('message is not retrieved from storage when message present', async () => {
- const storageStub = stubStorage('getDraftComment');
- const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- message: 'This is a message',
- line: 5,
- path: 'test',
- __editing: true,
- __draft: true,
- };
- await flush();
- assert.isTrue(loadSpy.called);
- assert.isFalse(storageStub.called);
- });
-
- test('message is retrieved from storage for drafts in edit', async () => {
- const storageStub = stubStorage('getDraftComment');
- const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- path: 'test',
- __editing: true,
- __draft: true,
- };
- await flush();
- assert.isTrue(loadSpy.called);
- assert.isTrue(storageStub.called);
- });
-
- test('comment message sets messageText only when empty', () => {
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element._messageText = '';
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- path: 'test',
- __editing: true,
- __draft: true,
- message: 'hello world',
- };
- // messageText was empty so overwrite the message now
- assert.equal(element._messageText, 'hello world');
-
- element.comment!.message = 'new message';
- // messageText was already set so do not overwrite it
- assert.equal(element._messageText, 'hello world');
- });
-
- test('comment message sets messageText when not edited', () => {
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element._messageText = 'Some text';
- element.comment = {
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- path: 'test',
- __editing: false,
- __draft: true,
- message: 'hello world',
- };
- // messageText was empty so overwrite the message now
- assert.equal(element._messageText, 'hello world');
-
- element.comment!.message = 'new message';
- // messageText was already set so do not overwrite it
- assert.equal(element._messageText, 'hello world');
- });
-
- test('_getPatchNum', () => {
- element.side = 'PARENT';
- element.patchNum = 1 as PatchSetNum;
- assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
- element.side = 'REVISION';
- assert.equal(element._getPatchNum(), 1 as PatchSetNum);
- });
-
- test('comment expand and collapse', () => {
- element.collapsed = true;
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are not visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isTrue(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is visible'
- );
-
- element.collapsed = false;
- assert.isFalse(element.collapsed);
- assert.isTrue(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is is not visible'
- );
- });
-
- suite('while editing', () => {
- let handleCancelStub: sinon.SinonStub;
- let handleSaveStub: sinon.SinonStub;
- setup(() => {
- element.editing = true;
- element._messageText = 'test';
- handleCancelStub = sinon.stub(element, '_handleCancel');
- handleSaveStub = sinon.stub(element, '_handleSave');
- flush();
- });
-
- suite('when text is empty', () => {
- setup(() => {
- element._messageText = '';
- element.comment = {};
- });
-
- test('esc closes comment when text is empty', () => {
- pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
- assert.isTrue(handleCancelStub.called);
- });
-
- test('ctrl+enter does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
- assert.isFalse(handleSaveStub.called);
- });
-
- test('meta+enter does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
- assert.isFalse(handleSaveStub.called);
- });
-
- test('ctrl+s does not save', () => {
- pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
- assert.isFalse(handleSaveStub.called);
- });
- });
-
- test('esc does not close comment that has content', () => {
- pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
- assert.isFalse(handleCancelStub.called);
- });
-
- test('ctrl+enter saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
- assert.isTrue(handleSaveStub.called);
- });
-
- test('meta+enter saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
- assert.isTrue(handleSaveStub.called);
- });
-
- test('ctrl+s saves', () => {
- pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
- assert.isTrue(handleSaveStub.called);
- });
- });
-
- test('delete comment button for non-admins is hidden', () => {
- element._isAdmin = false;
- assert.isFalse(
- queryAndAssert(element, '.action.delete').classList.contains(
- 'showDeleteButtons'
- )
- );
- });
-
- test('delete comment button for admins with draft is hidden', () => {
- element._isAdmin = false;
- element.draft = true;
- assert.isFalse(
- queryAndAssert(element, '.action.delete').classList.contains(
- 'showDeleteButtons'
- )
- );
- });
-
- test('delete comment', async () => {
- const stub = stubRestApi('deleteComment').returns(
- Promise.resolve(createComment())
- );
- const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
- element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element._isAdmin = true;
- assert.isTrue(
- queryAndAssert(element, '.action.delete').classList.contains(
- 'showDeleteButtons'
- )
- );
- tap(queryAndAssert(element, '.action.delete'));
- await flush();
- await openSpy.lastCall.returnValue;
- const dialog = element.confirmDeleteOverlay?.querySelector(
- '#confirmDeleteComment'
- ) as GrConfirmDeleteCommentDialog;
- dialog.message = 'removal reason';
- element._handleConfirmDeleteComment();
- assert.isTrue(
- stub.calledWith(
- 42 as NumericChangeId,
- 1 as PatchSetNum,
- 'baf0414d_60047215' as UrlEncodedCommentId,
- 'removal reason'
- )
- );
- });
-
- suite('draft update reporting', () => {
- let endStub: SinonStubbedMember<() => Timer>;
- let getTimerStub: sinon.SinonStub;
- const mockEvent = {...new Event('click'), preventDefault() {}};
-
- setup(() => {
- sinon.stub(element, 'save').returns(Promise.resolve({}));
- endStub = sinon.stub();
- const mockTimer = new MockTimer();
- mockTimer.end = endStub;
- getTimerStub = stubReporting('getTimer').returns(mockTimer);
- });
-
- test('create', async () => {
- element.patchNum = 1 as PatchSetNum;
- element.comment = {};
- sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
- await element._handleSave(mockEvent);
- await flush();
- const grAccountLabel = queryAndAssert(element, 'gr-account-label');
- const spanName = queryAndAssert<HTMLSpanElement>(
- grAccountLabel,
- 'span.name'
- );
- assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
- assert.isTrue(endStub.calledOnce);
- assert.isTrue(getTimerStub.calledOnce);
- assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
- });
-
- test('update', () => {
- element.comment = {
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
- };
- sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
- return element._handleSave(mockEvent)!.then(() => {
- assert.isTrue(endStub.calledOnce);
- assert.isTrue(getTimerStub.calledOnce);
- assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
- });
- });
-
- test('discard', () => {
- element.comment = {
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
- };
- element.comment = createDraft();
- sinon.stub(element, '_fireDiscard');
- sinon.stub(element, '_eraseDraftCommentFromStorage');
- sinon
- .stub(element, '_deleteDraft')
- .returns(Promise.resolve(new Response()));
- return element._discardDraft().then(() => {
- assert.isTrue(endStub.calledOnce);
- assert.isTrue(getTimerStub.calledOnce);
- assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
- });
- });
- });
-
- test('edit reports interaction', () => {
- const reportStub = stubReporting('recordDraftInteraction');
- sinon.stub(element, '_fireEdit');
- element.draft = true;
- flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('discard reports interaction', () => {
- const reportStub = stubReporting('recordDraftInteraction');
- sinon.stub(element, '_eraseDraftCommentFromStorage');
- sinon.stub(element, '_fireDiscard');
- sinon
- .stub(element, '_deleteDraft')
- .returns(Promise.resolve(new Response()));
- element.draft = true;
- element.comment = createDraft();
- flush();
- tap(queryAndAssert(element, '.discard'));
- assert.isTrue(reportStub.calledOnce);
- });
-
- test('failed save draft request', async () => {
- element.draft = true;
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const updateRequestStub = sinon.stub(element, '_updateRequestToast');
- const diffDraftStub = stubRestApi('saveDiffDraft').returns(
- Promise.resolve({...new Response(), ok: false})
- );
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- let args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0, true]);
- assert.equal(
- element._getSavingMessage(...args),
- __testOnly_UNSAVED_MESSAGE
- );
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT(Failed to save)'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.save')),
- 'save is visible'
- );
- diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0]);
- assert.equal(element._getSavingMessage(...args), 'All changes saved');
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(element._unableToSave);
- });
-
- test('failed save draft request with promise failure', async () => {
- element.draft = true;
- element.changeNum = 1 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- const updateRequestStub = sinon.stub(element, '_updateRequestToast');
- const diffDraftStub = stubRestApi('saveDiffDraft').returns(
- Promise.reject(new Error())
- );
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- let args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0, true]);
- assert.equal(
- element._getSavingMessage(...args),
- __testOnly_UNSAVED_MESSAGE
- );
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT(Failed to save)'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.save')),
- 'save is visible'
- );
- diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
- element._saveDraft({
- ...createComment(),
- id: 'abc_123' as UrlEncodedCommentId,
- });
- await flush();
- args = updateRequestStub.lastCall.args;
- assert.deepEqual(args, [0]);
- assert.equal(element._getSavingMessage(...args), 'All changes saved');
- assert.equal(
- (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
- 'DRAFT'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(element._unableToSave);
- });
});
- suite('gr-comment draft tests', () => {
- let element: GrComment;
+ test('clicking on date link fires event', async () => {
+ const stub = sinon.stub();
+ element.addEventListener('comment-anchor-tap', stub);
+ await element.updateComplete;
- setup(() => {
- stubRestApi('getAccount').returns(Promise.resolve(undefined));
- stubRestApi('saveDiffDraft').returns(
- Promise.resolve({
- ...new Response(),
- ok: true,
- text() {
- return Promise.resolve(
- ")]}'\n{" +
- '"id": "baf0414d_40572e03",' +
- '"path": "/path/to/file",' +
- '"line": 5,' +
- '"updated": "2015-12-08 21:52:36.177000000",' +
- '"message": "saved!",' +
- '"side": "REVISION",' +
- '"unresolved": false,' +
- '"patch_set": 1' +
- '}'
- );
- },
- })
- );
- stubRestApi('removeChangeReviewer').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- element = draftFixture.instantiate() as GrComment;
- stubStorage('getDraftComment').returns(null);
+ const dateEl = queryAndAssert(element, '.date');
+ tap(dateEl);
+
+ assert.isTrue(stub.called);
+ assert.deepEqual(stub.lastCall.args[0].detail, {
+ side: 'REVISION',
+ number: element.comment!.line,
+ });
+ });
+
+ test('comment message sets messageText only when empty', async () => {
+ element.changeNum = 1 as NumericChangeId;
+ element.messageText = '';
+ element.comment = {
+ ...createComment(),
+ author: {
+ name: 'Mr. Peanutbutter',
+ email: 'tenn1sballchaser@aol.com' as EmailAddress,
+ },
+ line: 5,
+ path: 'test',
+ __draft: true,
+ message: 'hello world',
+ };
+ element.editing = true;
+ await element.updateComplete;
+ // messageText was empty so overwrite the message now
+ assert.equal(element.messageText, 'hello world');
+
+ element.comment!.message = 'new message';
+ await element.updateComplete;
+ // messageText was already set so do not overwrite it
+ assert.equal(element.messageText, 'hello world');
+ });
+
+ test('comment message sets messageText when not edited', async () => {
+ element.changeNum = 1 as NumericChangeId;
+ element.messageText = 'Some text';
+ element.comment = {
+ ...createComment(),
+ author: {
+ name: 'Mr. Peanutbutter',
+ email: 'tenn1sballchaser@aol.com' as EmailAddress,
+ },
+ line: 5,
+ path: 'test',
+ __draft: true,
+ message: 'hello world',
+ };
+ element.editing = true;
+ await element.updateComplete;
+ // messageText was empty so overwrite the message now
+ assert.equal(element.messageText, 'hello world');
+
+ element.comment!.message = 'new message';
+ await element.updateComplete;
+ // messageText was already set so do not overwrite it
+ assert.equal(element.messageText, 'hello world');
+ });
+
+ test('delete comment', async () => {
+ element.changeNum = 42 as NumericChangeId;
+ element.isAdmin = true;
+ await element.updateComplete;
+
+ const deleteButton = queryAndAssert(element, '.action.delete');
+ tap(deleteButton);
+ await element.updateComplete;
+
+ assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
+ const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
+ element.confirmDeleteOverlay,
+ '#confirmDeleteComment'
+ );
+ dialog.message = 'removal reason';
+ await element.updateComplete;
+
+ const stub = stubRestApi('deleteComment').returns(
+ Promise.resolve(createComment())
+ );
+ element.handleConfirmDeleteComment();
+ assert.isTrue(
+ stub.calledWith(
+ 42 as NumericChangeId,
+ 1 as PatchSetNum,
+ 'baf0414d_60047215' as UrlEncodedCommentId,
+ 'removal reason'
+ )
+ );
+ });
+
+ suite('gr-comment draft tests', () => {
+ setup(async () => {
element.changeNum = 42 as NumericChangeId;
- element.patchNum = 1 as PatchSetNum;
- element.editing = false;
element.comment = {
...createComment(),
__draft: true,
- __draftID: 'temp_draft_id',
path: '/path/to/file',
line: 5,
- id: undefined,
};
});
- test('button visibility states', async () => {
- element.showActions = false;
- assert.isTrue(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
+ test('isSaveDisabled', async () => {
+ element.saving = false;
+ element.unresolved = true;
+ element.comment = {...createComment(), unresolved: true};
+ element.messageText = 'asdf';
+ await element.updateComplete;
+ assert.isFalse(element.isSaveDisabled());
- element.showActions = true;
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
+ element.messageText = '';
+ await element.updateComplete;
+ assert.isTrue(element.isSaveDisabled());
- element.draft = true;
- await flush();
- assert.isTrue(
- isVisible(queryAndAssert(element, '.edit')),
- 'edit is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.discard')),
- 'discard is visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.resolve')),
- 'resolve is visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
+ element.unresolved = false;
+ await element.updateComplete;
+ assert.isFalse(element.isSaveDisabled());
- element.editing = true;
- await flush();
- assert.isFalse(
- isVisible(queryAndAssert(element, '.edit')),
- 'edit is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.discard')),
- 'discard not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.save')),
- 'save is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.resolve')),
- 'resolve is visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- element.draft = false;
- element.editing = false;
- await flush();
- assert.isFalse(
- isVisible(queryAndAssert(element, '.edit')),
- 'edit is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.discard')),
- 'discard is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.save')),
- 'save is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is not visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- element.comment!.id = 'foo' as UrlEncodedCommentId;
- element.draft = true;
- element.editing = true;
- await flush();
- assert.isTrue(
- isVisible(queryAndAssert(element, '.cancel')),
- 'cancel is visible'
- );
- assert.isFalse(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isTrue(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- // Delete button is not hidden by default
- assert.isFalse(
- (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
- );
-
- element.isRobotComment = true;
- element.draft = true;
- assert.isTrue(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isFalse(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- // It is not expected to see Robot comment drafts, but if they appear,
- // they will behave the same as non-drafts.
- element.draft = false;
- assert.isTrue(
- queryAndAssert(element, '.humanActions').hasAttribute('hidden')
- );
- assert.isFalse(
- queryAndAssert(element, '.robotActions').hasAttribute('hidden')
- );
-
- // A robot comment with run ID should display plain text.
- element.set(['comment', 'robot_run_id'], 'text');
- element.editing = false;
- element.collapsed = false;
- await flush();
- assert.isTrue(
- queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
- );
-
- // A robot comment with run ID and url should display a link.
- element.set(['comment', 'url'], '/path/to/run');
- await flush();
- assert.notEqual(
- getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
- 'none'
- );
-
- // Delete button is hidden for robot comments
- assert.isTrue(
- (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
- );
- });
-
- test('collapsible drafts', async () => {
- const fireEditStub = sinon.stub(element, '_fireEdit');
- assert.isTrue(element.collapsed);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are not visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isTrue(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is visible'
- );
-
- tap(element.$.header);
- assert.isFalse(element.collapsed);
- assert.isTrue(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isNotOk(element.textarea, 'textarea is not visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is is not visible'
- );
-
- // When the edit button is pressed, should still see the actions
- // and also textarea
- element.draft = true;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- await flush();
- assert.isTrue(fireEditStub.called);
- assert.isFalse(element.collapsed);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is not visible'
- );
-
- // When toggle again, everything should be hidden except for textarea
- // and header middle content should be visible
- tap(element.$.header);
- assert.isTrue(element.collapsed);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are not visible'
- );
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-textarea')),
- 'textarea is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is visible'
- );
-
- // When toggle again, textarea should remain open in the state it was
- // before
- tap(element.$.header);
- assert.isFalse(
- isVisible(queryAndAssert(element, 'gr-formatted-text')),
- 'gr-formatted-text is not visible'
- );
- assert.isTrue(
- isVisible(queryAndAssert(element, '.actions')),
- 'actions are visible'
- );
- assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
- assert.isFalse(
- isVisible(queryAndAssert(element, '.collapsedContent')),
- 'header middle content is not visible'
- );
- });
-
- test('robot comment layout', async () => {
- const comment = {
- robot_id: 'happy_robot_id' as RobotId,
- url: '/robot/comment',
- author: {
- name: 'Happy Robot',
- display_name: 'Display name Robot',
- },
- ...element.comment,
- };
- element.comment = comment;
- element.collapsed = false;
- await flush;
- let runIdMessage;
- runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
- assert.isFalse((runIdMessage as HTMLElement).hidden);
-
- const runDetailsLink = queryAndAssert(
- element,
- '.robotRunLink'
- ) as HTMLAnchorElement;
- assert.isTrue(
- runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
- );
-
- const robotServiceName = queryAndAssert(element, '.robotName');
- assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
-
- const authorName = queryAndAssert(element, '.robotId');
- assert.isTrue((authorName as HTMLDivElement).innerText === 'Happy Robot');
-
- element.collapsed = true;
- await flush();
- runIdMessage = queryAndAssert(element, '.runIdMessage');
- assert.isTrue((runIdMessage as HTMLDivElement).hidden);
- });
-
- test('author name fallback to email', async () => {
- const comment = {
- url: '/robot/comment',
- author: {
- email: 'test@test.com' as EmailAddress,
- },
- ...element.comment,
- };
- element.comment = comment;
- element.collapsed = false;
- await flush();
- const authorName = queryAndAssert(
- queryAndAssert(element, 'gr-account-label'),
- 'span.name'
- ) as HTMLSpanElement;
- assert.equal(authorName.innerText.trim(), 'test@test.com');
- });
-
- test('patchset level comment', async () => {
- const fireEditStub = sinon.stub(element, '_fireEdit');
- const comment = {
- ...element.comment,
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- line: undefined,
- range: undefined,
- };
- element.comment = comment;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.called);
- assert.isTrue(element.editing);
-
- element._messageText = 'hello world';
- const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
- const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
- element._handleSave(mockEvent);
- await flush();
- assert.isTrue(eraseMessageDraftSpy.called);
- });
-
- test('draft creation/cancellation', async () => {
- const fireEditStub = sinon.stub(element, '_fireEdit');
- assert.isFalse(element.editing);
- element.draft = true;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.called);
- assert.isTrue(element.editing);
-
- element.comment!.message = '';
- element._messageText = '';
- const eraseMessageDraftSpy = sinon.spy(
- element,
- '_eraseDraftCommentFromStorage'
- );
-
- // Save should be disabled on an empty message.
- let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
- assert.isTrue(disabled, 'save button should be disabled.');
- element._messageText = ' ';
- disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
- assert.isTrue(disabled, 'save button should be disabled.');
-
- const updateStub = sinon.stub();
- element.addEventListener('comment-update', updateStub);
-
- let numDiscardEvents = 0;
- const promise = mockPromise();
- element.addEventListener('comment-discard', () => {
- numDiscardEvents++;
- assert.isFalse(eraseMessageDraftSpy.called);
- if (numDiscardEvents === 2) {
- assert.isFalse(updateStub.called);
- promise.resolve();
- }
- });
- tap(queryAndAssert(element, '.cancel'));
- await flush();
- element._messageText = '';
- element.editing = true;
- await flush();
- pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
- await promise;
- });
-
- test('draft discard removes message from storage', async () => {
- element._messageText = '';
- const eraseMessageDraftSpy = sinon.spy(
- element,
- '_eraseDraftCommentFromStorage'
- );
-
- const promise = mockPromise();
- element.addEventListener('comment-discard', () => {
- assert.isTrue(eraseMessageDraftSpy.called);
- promise.resolve();
- });
- element._handleDiscard({
- ...new Event('click'),
- preventDefault: sinon.stub(),
- });
- await promise;
- });
-
- test('storage is cleared only after save success', () => {
- element._messageText = 'test';
- const eraseStub = sinon.stub(element, '_eraseDraftCommentFromStorage');
- stubRestApi('getResponseObject').returns(
- Promise.resolve({...(createDraft() as ParsedJSON)})
- );
- const saveDraftStub = sinon
- .stub(element, '_saveDraft')
- .returns(Promise.resolve({...new Response(), ok: false}));
-
- const savePromise = element.save();
- assert.isFalse(eraseStub.called);
- return savePromise.then(() => {
- assert.isFalse(eraseStub.called);
-
- saveDraftStub.restore();
- sinon
- .stub(element, '_saveDraft')
- .returns(Promise.resolve({...new Response(), ok: true}));
- return element.save().then(() => {
- assert.isTrue(eraseStub.called);
- });
- });
- });
-
- test('_computeSaveDisabled', () => {
- const comment = {unresolved: true};
- const msgComment = {message: 'test', unresolved: true};
- assert.equal(element._computeSaveDisabled('', comment, false), true);
- assert.equal(element._computeSaveDisabled('test', comment, false), false);
- assert.equal(element._computeSaveDisabled('', msgComment, false), true);
- assert.equal(
- element._computeSaveDisabled('test', msgComment, false),
- false
- );
- assert.equal(
- element._computeSaveDisabled('test2', msgComment, false),
- false
- );
- assert.equal(element._computeSaveDisabled('test', comment, true), false);
- assert.equal(element._computeSaveDisabled('', comment, true), true);
- assert.equal(element._computeSaveDisabled('', comment, false), true);
+ element.saving = true;
+ await element.updateComplete;
+ assert.isTrue(element.isSaveDisabled());
});
test('ctrl+s saves comment', async () => {
- const promise = mockPromise();
- const stub = sinon.stub(element, 'save').callsFake(() => {
- assert.isTrue(stub.called);
- stub.restore();
- promise.resolve();
- return Promise.resolve();
- });
- element._messageText = 'is that the horse from horsing around??';
+ const spy = sinon.stub(element, 'save');
+ element.messageText = 'is that the horse from horsing around??';
element.editing = true;
- await flush();
- pressAndReleaseKeyOn(
- element.textarea!.$.textarea.textarea,
- 83,
- 'ctrl',
- 's'
- );
- await promise;
+ await element.updateComplete;
+ pressKey(element.textarea!.$.textarea.textarea, 's', Modifier.CTRL_KEY);
+ assert.isTrue(spy.called);
});
- test('draft saving/editing', async () => {
- const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
- const fireEditStub = sinon.stub(element, '_fireEdit');
- const clock: SinonFakeTimers = sinon.useFakeTimers();
- const tickAndFlush = async (repetitions: number) => {
- for (let i = 1; i <= repetitions; i++) {
- clock.tick(1000);
- await flush();
- }
- };
+ test('save', async () => {
+ const savePromise = mockPromise<void>();
+ const stub = stubComments('saveDraft').returns(savePromise);
- element.draft = true;
- await flush();
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.called);
- tickAndFlush(1);
- element._messageText = 'good news, everyone!';
- tickAndFlush(1);
- assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
- assert.isTrue(dispatchEventStub.calledTwice);
-
- element._messageText = 'good news, everyone!';
- await flush();
- assert.isTrue(dispatchEventStub.calledTwice);
-
- tap(queryAndAssert(element, '.save'));
-
- assert.isTrue(
- element.disabled,
- 'Element should be disabled when creating draft.'
- );
-
- let draft = await element._xhrPromise!;
- const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
- comment: DraftInfo;
- }>;
- assert.equal(evt.type, 'comment-save');
-
- const expectedDetail = {
- comment: {
- ...createComment(),
- __draft: true,
- __draftID: 'temp_draft_id',
- id: 'baf0414d_40572e03' as UrlEncodedCommentId,
- line: 5,
- message: 'saved!',
- path: '/path/to/file',
- updated: '2015-12-08 21:52:36.177000000' as Timestamp,
- },
- patchNum: 1 as PatchSetNum,
- };
-
- assert.deepEqual(evt.detail, expectedDetail);
- assert.isFalse(
- element.disabled,
- 'Element should be enabled when done creating draft.'
- );
- assert.equal(draft.message, 'saved!');
- assert.isFalse(element.editing);
- tap(queryAndAssert(element, '.edit'));
- assert.isTrue(fireEditStub.calledTwice);
- element._messageText =
- 'You’ll be delivering a package to Chapek 9, ' +
- 'a world where humans are killed on sight.';
- tap(queryAndAssert(element, '.save'));
- assert.isTrue(
- element.disabled,
- 'Element should be disabled when updating draft.'
- );
- draft = await element._xhrPromise!;
- assert.isFalse(
- element.disabled,
- 'Element should be enabled when done updating draft.'
- );
- assert.equal(draft.message, 'saved!');
- assert.isFalse(element.editing);
- dispatchEventStub.restore();
- });
-
- test('draft prevent save when disabled', async () => {
- const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
- sinon.stub(element, '_fireEdit');
- element.showActions = true;
- element.draft = true;
- await flush();
- tap(element.$.header);
- tap(queryAndAssert(element, '.edit'));
- element._messageText = 'good news, everyone!';
- await flush();
-
- element.disabled = true;
- tap(queryAndAssert(element, '.save'));
- assert.isFalse(saveStub.called);
-
- element.disabled = false;
- tap(queryAndAssert(element, '.save'));
- assert.isTrue(saveStub.calledOnce);
- });
-
- test('proper event fires on resolve, comment is not saved', async () => {
- const save = sinon.stub(element, 'save');
- const promise = mockPromise();
- element.addEventListener('comment-update', e => {
- assert.isTrue(e.detail.comment.unresolved);
- assert.isFalse(save.called);
- promise.resolve();
- });
- tap(queryAndAssert(element, '.resolve input'));
- await promise;
- });
-
- test('resolved comment state indicated by checkbox', () => {
- sinon.stub(element, 'save');
- element.comment = {unresolved: false};
- assert.isTrue(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- element.comment = {unresolved: true};
- assert.isFalse(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- });
-
- test('resolved checkbox saves with tap when !editing', () => {
- element.editing = false;
- const save = sinon.stub(element, 'save');
-
- element.comment = {unresolved: false};
- assert.isTrue(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- element.comment = {unresolved: true};
- assert.isFalse(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- assert.isFalse(save.called);
- tap(element.$.resolvedCheckbox);
- assert.isTrue(
- (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
- );
- assert.isTrue(save.called);
- });
-
- suite('draft saving messages', () => {
- test('_getSavingMessage', () => {
- assert.equal(element._getSavingMessage(0), 'All changes saved');
- assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
- assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
- assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
- });
-
- test('_show{Start,End}Request', () => {
- const updateStub = sinon.stub(element, '_updateRequestToast');
- element._numPendingDraftRequests.number = 1;
-
- element._showStartRequest();
- assert.isTrue(updateStub.calledOnce);
- assert.equal(updateStub.lastCall.args[0], 2);
- assert.equal(element._numPendingDraftRequests.number, 2);
-
- element._showEndRequest();
- assert.isTrue(updateStub.calledTwice);
- assert.equal(updateStub.lastCall.args[0], 1);
- assert.equal(element._numPendingDraftRequests.number, 1);
-
- element._showEndRequest();
- assert.isTrue(updateStub.calledThrice);
- assert.equal(updateStub.lastCall.args[0], 0);
- assert.equal(element._numPendingDraftRequests.number, 0);
- });
- });
-
- test('cancelling an unsaved draft discards, persists in storage', async () => {
- const clock: SinonFakeTimers = sinon.useFakeTimers();
- const tickAndFlush = async (repetitions: number) => {
- for (let i = 1; i <= repetitions; i++) {
- clock.tick(1000);
- await flush();
- }
- };
- const discardSpy = sinon.spy(element, '_fireDiscard');
- const storeStub = stubStorage('setDraftComment');
- const eraseStub = stubStorage('eraseDraftComment');
- element.comment!.id = undefined; // set id undefined for draft
- element._messageText = 'test text';
- tickAndFlush(1);
-
- assert.isTrue(storeStub.called);
- assert.equal(storeStub.lastCall.args[1], 'test text');
- element._handleCancel();
- await flush();
- assert.isTrue(discardSpy.called);
- assert.isFalse(eraseStub.called);
- });
-
- test('cancelling edit on a saved draft does not store', () => {
- element.comment!.id = 'foo' as UrlEncodedCommentId;
- const discardSpy = sinon.spy(element, '_fireDiscard');
- const storeStub = stubStorage('setDraftComment');
- element.comment!.id = undefined; // set id undefined for draft
- element._messageText = 'test text';
- flush();
-
- assert.isFalse(storeStub.called);
- element._handleCancel();
- assert.isTrue(discardSpy.called);
- });
-
- test('deleting text from saved draft and saving deletes the draft', () => {
- element.comment = {
- ...createComment(),
- id: 'foo' as UrlEncodedCommentId,
- message: 'test',
- };
- element._messageText = '';
- const discardStub = sinon.stub(element, '_discardDraft');
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+ const textToSave = 'something, not important';
+ element.messageText = textToSave;
+ element.unresolved = true;
+ await element.updateComplete;
element.save();
- assert.isTrue(discardStub.called);
+
+ await element.updateComplete;
+ waitUntilCalled(stub, 'saveDraft()');
+ assert.equal(stub.lastCall.firstArg.message, textToSave);
+ assert.equal(stub.lastCall.firstArg.unresolved, true);
+ assert.isTrue(element.editing);
+ assert.isTrue(element.saving);
+
+ savePromise.resolve();
+ await element.updateComplete;
+
+ assert.isFalse(element.editing);
+ assert.isFalse(element.saving);
});
- test('_handleFix fires create-fix event', async () => {
- const promise = mockPromise();
- element.addEventListener(
- 'create-fix-comment',
- (e: CreateFixCommentEvent) => {
- assert.deepEqual(e.detail, element._getEventPayload());
- promise.resolve();
- }
+ test('save failed', async () => {
+ stubComments('saveDraft').returns(
+ Promise.reject(new Error('saving failed'))
);
- element.isRobotComment = true;
- element.comments = [element.comment!];
- await flush();
- tap(queryAndAssert(element, '.fix'));
- await promise;
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+ element.messageText = 'something, not important';
+ await element.updateComplete;
+
+ element.save();
+ await element.updateComplete;
+
+ assert.isTrue(element.unableToSave);
+ assert.isTrue(element.editing);
+ assert.isFalse(element.saving);
});
- test('do not show Please Fix button if human reply exists', () => {
- element.comments = [
- {
- robot_id: 'happy_robot_id' as RobotId,
- robot_run_id: '5838406743490560' as RobotRunId,
- fix_suggestions: [
- {
- fix_id: '478ff847_3bf47aaf' as FixId,
- description: 'Make the smiley happier by giving it a nose.',
- replacements: [
- {
- path: 'Documentation/config-gerrit.txt',
- range: {
- start_line: 10,
- start_character: 7,
- end_line: 10,
- end_character: 9,
- },
- replacement: ':-)',
- },
- ],
- },
- ],
- author: {
- _account_id: 1030912 as AccountId,
- name: 'Alice Kober-Sotzek',
- email: 'aliceks@google.com' as EmailAddress,
- avatars: [
- {
- url: '/s32-p/photo.jpg',
- height: 32,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s56-p/photo.jpg',
- height: 56,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s100-p/photo.jpg',
- height: 100,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s120-p/photo.jpg',
- height: 120,
- width: 32,
- },
- ],
- },
- patch_set: 1 as PatchSetNum,
- ...createComment(),
- id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
- line: 10,
- updated: '2017-04-04 15:36:17.000000000' as Timestamp,
- message: 'This is a robot comment with a fix.',
- unresolved: false,
- collapsed: false,
- },
- {
- __draft: true,
- __draftID: '0.wbrfbwj89sa',
- __date: new Date(),
- path: 'Documentation/config-gerrit.txt',
- side: CommentSide.REVISION,
- line: 10,
- in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
- message: '> This is a robot comment with a fix.\n\nPlease fix.',
- unresolved: true,
- },
- ];
- element.comment = element.comments[0];
- flush();
- assert.isNull(
- element.shadowRoot?.querySelector('robotActions gr-button')
- );
+ test('discard', async () => {
+ const discardPromise = mockPromise<void>();
+ const stub = stubComments('discardDraft').returns(discardPromise);
+
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+
+ element.discard();
+
+ await element.updateComplete;
+ waitUntilCalled(stub, 'discardDraft()');
+ assert.equal(stub.lastCall.firstArg, element.comment.id);
+ assert.isTrue(element.editing);
+ assert.isTrue(element.saving);
+
+ discardPromise.resolve();
+ await element.updateComplete;
+
+ assert.isFalse(element.editing);
+ assert.isFalse(element.saving);
});
- test('show Please Fix if no human reply', () => {
- element.comments = [
- {
- robot_id: 'happy_robot_id' as RobotId,
- robot_run_id: '5838406743490560' as RobotRunId,
- fix_suggestions: [
- {
- fix_id: '478ff847_3bf47aaf' as FixId,
- description: 'Make the smiley happier by giving it a nose.',
- replacements: [
- {
- path: 'Documentation/config-gerrit.txt',
- range: {
- start_line: 10,
- start_character: 7,
- end_line: 10,
- end_character: 9,
- },
- replacement: ':-)',
- },
- ],
- },
- ],
- author: {
- _account_id: 1030912 as AccountId,
- name: 'Alice Kober-Sotzek',
- email: 'aliceks@google.com' as EmailAddress,
- avatars: [
- {
- url: '/s32-p/photo.jpg',
- height: 32,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s56-p/photo.jpg',
- height: 56,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s100-p/photo.jpg',
- height: 100,
- width: 32,
- },
- {
- url: '/AaAdOFzPlFI/s120-p/photo.jpg',
- height: 120,
- width: 32,
- },
- ],
- },
- patch_set: 1 as PatchSetNum,
- ...createComment(),
- id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
- line: 10,
- updated: '2017-04-04 15:36:17.000000000' as Timestamp,
- message: 'This is a robot comment with a fix.',
- unresolved: false,
- collapsed: false,
- },
- ];
- element.comment = element.comments[0];
- flush();
- queryAndAssert(element, '.robotActions gr-button');
- });
-
- test('_handleShowFix fires open-fix-preview event', async () => {
- const promise = mockPromise();
- element.addEventListener('open-fix-preview', e => {
- assert.deepEqual(e.detail, element._getEventPayload());
- promise.resolve();
- });
+ test('resolved comment state indicated by checkbox', async () => {
+ const saveStub = sinon.stub(element, 'save');
element.comment = {
...createComment(),
+ __draft: true,
+ unresolved: false,
+ };
+ await element.updateComplete;
+
+ let checkbox = queryAndAssert<HTMLInputElement>(
+ element,
+ '#resolvedCheckbox'
+ );
+ assert.isTrue(checkbox.checked);
+
+ tap(checkbox);
+ await element.updateComplete;
+
+ checkbox = queryAndAssert<HTMLInputElement>(element, '#resolvedCheckbox');
+ assert.isFalse(checkbox.checked);
+
+ assert.isTrue(saveStub.called);
+ });
+
+ test('saving empty text calls discard()', async () => {
+ const saveStub = stubComments('saveDraft');
+ const discardStub = stubComments('discardDraft');
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+
+ element.messageText = '';
+ await element.updateComplete;
+
+ await element.save();
+ assert.isTrue(discardStub.called);
+ assert.isFalse(saveStub.called);
+ });
+
+ test('handleFix fires create-fix event', async () => {
+ const listener = listenOnce<CreateFixCommentEvent>(
+ element,
+ 'create-fix-comment'
+ );
+ element.comment = createRobotComment();
+ element.comments = [element.comment!];
+ await element.updateComplete;
+
+ tap(queryAndAssert(element, '.fix'));
+
+ const e = await listener;
+ assert.deepEqual(e.detail, element.getEventPayload());
+ });
+
+ test('do not show Please Fix button if human reply exists', async () => {
+ element.initiallyCollapsed = false;
+ const robotComment = createRobotComment();
+ element.comment = robotComment;
+ await element.updateComplete;
+
+ let actions = query(element, '.robotActions gr-button.fix');
+ assert.isOk(actions);
+
+ element.comments = [
+ robotComment,
+ {...createComment(), in_reply_to: robotComment.id},
+ ];
+ await element.updateComplete;
+ actions = query(element, '.robotActions gr-button.fix');
+ assert.isNotOk(actions);
+ });
+
+ test('handleShowFix fires open-fix-preview event', async () => {
+ const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
+ element,
+ 'open-fix-preview'
+ );
+ element.comment = {
+ ...createRobotComment(),
fix_suggestions: [{...createFixSuggestionInfo()}],
};
- element.isRobotComment = true;
- await flush();
+ await element.updateComplete;
tap(queryAndAssert(element, '.show-fix'));
- await promise;
+
+ const e = await listener;
+ assert.deepEqual(e.detail, element.getEventPayload());
+ });
+ });
+
+ suite('auto saving', () => {
+ let clock: sinon.SinonFakeTimers;
+ let savePromise: MockPromise<void>;
+ let saveStub: SinonStub;
+
+ setup(async () => {
+ clock = sinon.useFakeTimers();
+ savePromise = mockPromise<void>();
+ saveStub = stubComments('saveDraft').returns(savePromise);
+
+ element.comment = createDraft();
+ element.editing = true;
+ await element.updateComplete;
+ });
+
+ teardown(() => {
+ clock.restore();
+ sinon.restore();
+ });
+
+ test('basic auto saving', async () => {
+ const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+ dispatch(textarea, 'text-changed', {value: 'some new text '});
+
+ clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS / 2);
+ assert.isFalse(saveStub.called);
+
+ clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS);
+ assert.isTrue(saveStub.called);
+ assert.equal(
+ saveStub.firstCall.firstArg.message,
+ 'some new text '.trimEnd()
+ );
+ });
+
+ test('saving while auto saving', async () => {
+ const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+ dispatch(textarea, 'text-changed', {value: 'auto save text'});
+
+ clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
+ assert.isTrue(saveStub.called);
+ assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
+ saveStub.reset();
+
+ element.messageText = 'actual save text';
+ element.save();
+ await element.updateComplete;
+ // First wait for the auto saving to finish.
+ assert.isFalse(saveStub.called);
+
+ savePromise.resolve();
+ await element.updateComplete;
+ // Only then save.
+ assert.isTrue(saveStub.called);
+ assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
});
});
suite('respectful tips', () => {
- let element: GrComment;
-
let clock: sinon.SinonFakeTimers;
- setup(() => {
- stubRestApi('getAccount').returns(Promise.resolve(undefined));
+ setup(async () => {
clock = sinon.useFakeTimers();
});
@@ -1645,81 +680,81 @@
});
test('show tip when no cached record', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 0;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isTrue(respectfulSetStub.called);
- assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+ queryAndAssert(element, '.respectfulReviewTip');
});
test('add 14-day delays once dismissed', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 0;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isTrue(respectfulSetStub.called);
assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
- assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+ const closeLink = queryAndAssert(element, '.respectfulReviewTip a.close');
+ tap(closeLink);
+ await element.updateComplete;
- tap(queryAndAssert(element, '.respectfulReviewTip .close'));
- flush();
assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
});
test('do not show tip when fall out of probability', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 3;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 2;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isFalse(respectfulSetStub.called);
assert.isNotOk(query(element, '.respectfulReviewTip'));
});
test('show tip when editing changed to true', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns(null);
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: false};
- await flush();
+ element.editing = false;
+ element.getRandomInt = () => 0;
+ element.comment = createComment();
+ await element.updateComplete;
+
assert.isFalse(respectfulGetStub.called);
assert.isFalse(respectfulSetStub.called);
assert.isNotOk(query(element, '.respectfulReviewTip'));
element.editing = true;
- await flush();
+ await element.updateComplete;
assert.isTrue(respectfulGetStub.called);
assert.isTrue(respectfulSetStub.called);
assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
});
test('no tip when cached record', async () => {
- element = draftFixture.instantiate() as GrComment;
const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
respectfulGetStub.returns({updated: 0});
- // fake random
- element.getRandomNum = () => 0;
- element.comment = {__editing: true, __draft: true};
- await flush();
+ element.editing = true;
+ element.getRandomInt = () => 0;
+ element.comment = createDraft();
+ await element.updateComplete;
+
assert.isTrue(respectfulGetStub.called);
assert.isFalse(respectfulSetStub.called);
assert.isNotOk(query(element, '.respectfulReviewTip'));
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 6180f35..a39d033 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -78,7 +78,7 @@
@property({type: Number})
initialCount = 75;
- @property({type: Object})
+ @property({type: Array})
items?: DropdownItem[];
@property({type: String})
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index 996edf3..1088b27 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -17,9 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../change/gr-change-actions/gr-change-actions.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-annotation-actions-js-api tests', () => {
let annotationActions;
@@ -27,7 +24,7 @@
let plugin;
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
annotationActions = plugin.annotationApi();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
index 87f6052..b70c8ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -19,12 +19,8 @@
import '../../change/gr-change-actions/gr-change-actions.js';
import {resetPlugins} from '../../../test/test-utils.js';
import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
const basicFixture = fixtureFromElement('gr-change-actions');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-change-actions-js-api-interface tests', () => {
let element;
let changeActions;
@@ -41,7 +37,7 @@
suite('early init', () => {
setup(() => {
resetPlugins();
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
// Mimic all plugins loaded.
getPluginLoader().loadPlugins([]);
@@ -68,7 +64,7 @@
sinon.stub(element, '_editStatusChanged');
element.change = {};
element._hasKnownChainState = false;
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
changeActions = plugin.changeActions();
// Mimic all plugins loaded.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
index 2324588..52d6ab3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -17,16 +17,12 @@
import '../../../test/common-test-setup-karma.js';
import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {stubRestApi} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-reply-dialog');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-change-reply-js-api tests', () => {
let element;
-
let changeReply;
let plugin;
@@ -36,7 +32,7 @@
suite('early init', () => {
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
changeReply = plugin.changeReply();
element = basicFixture.instantiate();
@@ -64,7 +60,7 @@
suite('normal init', () => {
setup(() => {
element = basicFixture.instantiate();
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
changeReply = plugin.changeReply();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index d76b2b7..07fad80 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -81,12 +81,12 @@
Auth: AuthService;
}
-export function initGerritPluginApi() {
- window.Gerrit = window.Gerrit ?? new GerritImpl(getAppContext());
+export function initGerritPluginApi(appContext: AppContext) {
+ window.Gerrit = window.Gerrit ?? new GerritImpl(appContext);
}
-export function _testOnly_initGerritPluginApi(): GerritInternal {
- initGerritPluginApi();
+export function _testOnly_getGerritInternalPluginApi(): GerritInternal {
+ if (!window.Gerrit) throw new Error('initGerritPluginApi was not called');
return window.Gerrit as GerritInternal;
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
index ae0c370..d53c266 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -18,7 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import {getPluginLoader} from './gr-plugin-loader.js';
import {resetPlugins} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {_testOnly_getGerritInternalPluginApi} from './gr-gerrit.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {getAppContext} from '../../../services/app-context.js';
@@ -33,7 +33,7 @@
stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
stubRestApi('send').returns(Promise.resolve({status: 200}));
element = getAppContext().jsApiService;
- pluginApi = _testOnly_initGerritPluginApi();
+ pluginApi = _testOnly_getGerritInternalPluginApi();
});
teardown(() => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index a48f91c..c45bbf5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -21,13 +21,10 @@
import {EventType} from '../../../api/plugin.js';
import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {getAppContext} from '../../../services/app-context.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('GrJsApiInterface tests', () => {
let element;
let plugin;
@@ -47,7 +44,7 @@
sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
element = getAppContext().jsApiService;
errorStub = sinon.stub(element.reporting, 'error');
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
getPluginLoader().loadPlugins([]);
});
@@ -300,7 +297,7 @@
setup(() => {
stubBaseUrl('/r');
- pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
+ window.Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
'http://test.com/r/plugins/baseurlplugin/static/test.js');
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index 34c976a..d4b93a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -18,18 +18,15 @@
import '../../../test/common-test-setup-karma.js';
import './gr-js-api-interface.js';
import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {addListenerForTest} from '../../../test/test-utils.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-plugin-action-context tests', () => {
let instance;
let plugin;
setup(() => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
instance = new GrPluginActionContext(plugin);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index c7bdfb4..16846f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -18,12 +18,9 @@
import {resetPlugins} from '../../../test/test-utils';
import './gr-js-api-interface';
import {GrPluginEndpoints} from './gr-plugin-endpoints';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit';
import {PluginApi} from '../../../api/plugin';
import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
-const pluginApi = _testOnly_initGerritPluginApi();
-
export class MockHook<T extends PluginElement> implements HookApi<T> {
handleInstanceDetached(_: T) {}
@@ -59,7 +56,7 @@
setup(() => {
domHook = new MockHook<PluginElement>();
instance = new GrPluginEndpoints();
- pluginApi.install(
+ window.Gerrit.install(
plugin => (decoratePlugin = plugin),
'0.1',
'http://test.com/plugins/testplugin/static/decorate.js'
@@ -70,7 +67,7 @@
moduleName: 'decorate-module',
domHook,
});
- pluginApi.install(
+ window.Gerrit.install(
plugin => (stylePlugin = plugin),
'0.1',
'http://test.com/plugins/testplugin/static/style.js'
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index ab69267..e097858 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -19,11 +19,8 @@
import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-plugin-loader tests', () => {
let plugin;
@@ -47,18 +44,18 @@
});
test('reuse plugin for install calls', () => {
- pluginApi.install(p => { plugin = p; }, '0.1',
+ window.Gerrit.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
let otherPlugin;
- pluginApi.install(p => { otherPlugin = p; }, '0.1',
+ window.Gerrit.install(p => { otherPlugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
assert.strictEqual(plugin, otherPlugin);
});
test('versioning', () => {
const callback = sinon.spy();
- pluginApi.install(callback, '0.0pre-alpha');
+ window.Gerrit.install(callback, '0.0pre-alpha');
assert(callback.notCalled);
});
@@ -89,7 +86,7 @@
test('plugins installed successfully', async () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => void 0, undefined, url);
+ window.Gerrit.install(() => void 0, undefined, url);
});
const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
'pluginsLoaded');
@@ -107,7 +104,7 @@
test('isPluginEnabled and isPluginLoaded', () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => void 0, undefined, url);
+ window.Gerrit.install(() => void 0, undefined, url);
});
const plugins = [
@@ -137,7 +134,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
if (url === plugins[0]) {
throw new Error('failed');
}
@@ -165,7 +162,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
if (url === plugins[0]) {
throw new Error('failed');
}
@@ -198,7 +195,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
throw new Error('failed');
}, undefined, url);
});
@@ -224,7 +221,7 @@
addListenerForTest(document, 'show-alert', alertStub);
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => {
+ window.Gerrit.install(() => {
}, url === plugins[0] ? '' : 'alpha', url);
});
@@ -241,7 +238,7 @@
test('multiple assets for same plugin installed successfully', async () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => void 0, undefined, url);
+ window.Gerrit.install(() => void 0, undefined, url);
});
const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
'pluginsLoaded');
@@ -388,7 +385,7 @@
}
}
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
- pluginApi.install(() => pluginCallback(url), undefined, url);
+ window.Gerrit.install(() => pluginCallback(url), undefined, url);
});
pluginLoader.loadPlugins(plugins);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
index d2b5658..730f163 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -18,11 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-js-api-interface.js';
import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {stubRestApi} from '../../../test/test-utils.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-plugin-rest-api tests', () => {
let instance;
let getResponseObjectStub;
@@ -33,7 +30,7 @@
getResponseObjectStub = stubRestApi('getResponseObject').returns(
Promise.resolve());
sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
- pluginApi.install(p => {}, '0.1',
+ window.Gerrit.install(p => {}, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
instance = new GrPluginRestApi();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
index a0f2e02..c96a075 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {GerritInternal, _testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {getAppContext} from '../../../services/app-context.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {PluginApi} from '../../../api/plugin.js';
@@ -27,10 +26,8 @@
suite('gr-reporting-js-api tests', () => {
let plugin: PluginApi;
let reportingService: ReportingService;
- let pluginApi: GerritInternal;
setup(() => {
- pluginApi = _testOnly_initGerritPluginApi();
stubRestApi('getAccount').returns(Promise.resolve(undefined));
reportingService = getAppContext().reportingService;
});
@@ -38,7 +35,7 @@
suite('early init', () => {
let reporting: ReportingPluginApi;
setup(() => {
- pluginApi.install(
+ window.Gerrit.install(
p => {
plugin = p;
},
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-impl.ts
similarity index 98%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
index 0ce7f0d..37b14b3 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-impl.ts
@@ -156,6 +156,7 @@
import {ParsedChangeInfo} from '../../../types/types';
import {ErrorCallback} from '../../../api/rest';
import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
+import {addDraftProp, DraftInfo} from '../../../utils/comment-util';
const MAX_PROJECT_RESULTS = 25;
@@ -269,12 +270,12 @@
declare global {
interface HTMLElementTagNameMap {
- 'gr-rest-api-interface': GrRestApiInterface;
+ 'gr-rest-api-service-impl': GrRestApiServiceImpl;
}
}
-@customElement('gr-rest-api-interface')
-export class GrRestApiInterface
+@customElement('gr-rest-api-service-impl')
+export class GrRestApiServiceImpl
extends PolymerElement
implements RestApiService, Finalizable
{
@@ -2276,45 +2277,16 @@
* is no logged in user, the request is not made and the promise yields an
* empty object.
*/
- getDiffDrafts(
+ async getDiffDrafts(
changeNum: NumericChangeId
- ): Promise<PathToCommentsInfoMap | undefined>;
-
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum: BasePatchSetNum,
- patchNum: PatchSetNum,
- path: string
- ): Promise<GetDiffCommentsOutput>;
-
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum?: BasePatchSetNum,
- patchNum?: PatchSetNum,
- path?: string
- ) {
- return this.getLoggedIn().then(loggedIn => {
- if (!loggedIn) {
- return {};
- }
- if (!basePatchNum && !patchNum && !path) {
- return this._getDiffComments(changeNum, '/drafts', {
- 'enable-context': true,
- 'context-padding': 3,
- });
- }
- return this._getDiffComments(
- changeNum,
- '/drafts',
- {
- 'enable-context': true,
- 'context-padding': 3,
- },
- basePatchNum,
- patchNum,
- path
- );
+ ): Promise<{[path: string]: DraftInfo[]} | undefined> {
+ const loggedIn = await this.getLoggedIn();
+ if (!loggedIn) return {};
+ const comments = await this._getDiffComments(changeNum, '/drafts', {
+ 'enable-context': true,
+ 'context-padding': 3,
});
+ return addDraftProp(comments);
}
_setRange(comments: CommentInfo[], comment: CommentInfo) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
similarity index 99%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
index 0d3978a..b3f751a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl_test.js
@@ -27,9 +27,9 @@
readResponsePayload,
} from './gr-rest-apis/gr-rest-api-helper.js';
import {JSON_PREFIX} from './gr-rest-apis/gr-rest-api-helper.js';
-import {GrRestApiInterface} from './gr-rest-api-interface.js';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl.js';
-suite('gr-rest-api-interface tests', () => {
+suite('gr-rest-api-service-impl tests', () => {
let element;
let ctr = 0;
@@ -51,7 +51,10 @@
// fake auth
sinon.stub(getAppContext().authService, 'authCheck')
.returns(Promise.resolve(true));
- element = new GrRestApiInterface();
+ element = new GrRestApiServiceImpl(
+ getAppContext().authService,
+ getAppContext().flagsService
+ );
element._projectLookup = {};
});
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 8b44242..b602a87 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -424,6 +424,9 @@
}
_handleTextChanged(text: string) {
+ // This is a bit redundant, because the `text` property has `notify:true`,
+ // so whenever the `text` changes the component fires two identical events
+ // `text-changed` and `value-changed`.
this.dispatchEvent(
new CustomEvent('value-changed', {detail: {value: text}})
);
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index 17e0994..9b3e75d 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -16,7 +16,7 @@
*/
import {create, Registry, Finalizable} from '../services/registry';
-import {AppContext, injectAppContext} from '../services/app-context';
+import {AppContext} from '../services/app-context';
import {AuthService} from '../services/gr-auth/gr-auth';
import {FlagsService} from '../services/flags/flags';
import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
@@ -61,12 +61,10 @@
}
}
-let appContext: (AppContext & Finalizable) | undefined;
-
// Setup mocks for appContext.
// This is a temporary solution
// TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
+export function createDiffAppContext(): AppContext & Finalizable {
const appRegistry: Registry<AppContext> = {
flagsService: (_ctx: Partial<AppContext>) => new MockFlagsService(),
authService: (_ctx: Partial<AppContext>) => new MockAuthService(),
@@ -77,14 +75,14 @@
restApiService: (_ctx: Partial<AppContext>) => {
throw new Error('restApiService is not implemented');
},
- changeService: (_ctx: Partial<AppContext>) => {
- throw new Error('changeService is not implemented');
+ changeModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('changeModel is not implemented');
},
- commentsService: (_ctx: Partial<AppContext>) => {
- throw new Error('commentsService is not implemented');
+ commentsModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('commentsModel is not implemented');
},
- checksService: (_ctx: Partial<AppContext>) => {
- throw new Error('checksService is not implemented');
+ checksModel: (_ctx: Partial<AppContext>) => {
+ throw new Error('checksModel is not implemented');
},
jsApiService: (_ctx: Partial<AppContext>) => {
throw new Error('jsApiService is not implemented');
@@ -105,6 +103,5 @@
throw new Error('browserModel is not implemented');
},
};
- appContext = create<AppContext>(appRegistry);
- injectAppContext(appContext);
+ return create<AppContext>(appRegistry);
}
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
index 7f964f4..bb46484 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
@@ -16,18 +16,11 @@
*/
import '../test/common-test-setup-karma.js';
-import {getAppContext} from '../services/app-context.js';
-import {
- initDiffAppContext,
-} from './gr-diff-app-context-init.js';
+import {createDiffAppContext} from './gr-diff-app-context-init.js';
suite('gr diff app context initializer tests', () => {
- setup(() => {
- initDiffAppContext();
- });
-
test('all services initialized and are singletons', () => {
- const appContext = getAppContext();
+ const appContext = createDiffAppContext();
Object.keys(appContext).forEach(serviceName => {
const service = appContext[serviceName];
assert.isNotNull(service);
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 422667a4..64ef214 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -28,11 +28,12 @@
import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
-import {initDiffAppContext} from './gr-diff-app-context-init';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+import {injectAppContext} from '../services/app-context';
// Setup appContext for diff.
// TODO (dmfilippov): find a better solution
-initDiffAppContext();
+injectAppContext(createDiffAppContext());
// Setup global variables for existing usages of this component
window.grdiff = {
GrAnnotation,
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index bfc56b4..46d2178 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -20,13 +20,13 @@
import {GrReporting} from './gr-reporting/gr-reporting_impl';
import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
import {Auth} from './gr-auth/gr-auth_impl';
-import {GrRestApiInterface} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
+import {GrRestApiServiceImpl} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
+import {ChangeModel} from './change/change-model';
+import {ChecksModel} from './checks/checks-model';
import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
import {GrStorageService} from './storage/gr-storage_impl';
import {UserModel} from './user/user-model';
-import {CommentsService} from './comments/comments-service';
+import {CommentsModel} from './comments/comments-model';
import {ShortcutsService} from './shortcuts/shortcuts-service';
import {BrowserModel} from './browser/browser-model';
import {assertIsDefined} from '../utils/common-util';
@@ -51,19 +51,27 @@
restApiService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.authService, 'authService');
assertIsDefined(ctx.flagsService, 'flagsService)');
- return new GrRestApiInterface(ctx.authService!, ctx.flagsService!);
+ return new GrRestApiServiceImpl(ctx.authService!, ctx.flagsService!);
},
- changeService: (ctx: Partial<AppContext>) => {
+ changeModel: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.restApiService, 'restApiService');
- return new ChangeService(ctx.restApiService!);
+ return new ChangeModel(ctx.restApiService!);
},
- commentsService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new CommentsService(ctx.restApiService!);
+ commentsModel: (ctx: Partial<AppContext>) => {
+ const changeModel = ctx.changeModel;
+ const restApiService = ctx.restApiService;
+ const reporting = ctx.reportingService;
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(restApiService, 'restApiService');
+ assertIsDefined(reporting, 'reportingService');
+ return new CommentsModel(changeModel, restApiService, reporting);
},
- checksService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new ChecksService(ctx.reportingService!);
+ checksModel: (ctx: Partial<AppContext>) => {
+ const changeModel = ctx.changeModel;
+ const reporting = ctx.reportingService;
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(reporting, 'reportingService');
+ return new ChecksModel(changeModel, reporting);
},
jsApiService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.reportingService, 'reportingService');
@@ -71,8 +79,11 @@
},
storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
configModel: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new ConfigModel(ctx.restApiService!);
+ const changeModel = ctx.changeModel;
+ const restApiService = ctx.restApiService;
+ assertIsDefined(changeModel, 'changeModel');
+ assertIsDefined(restApiService, 'restApiService');
+ return new ConfigModel(changeModel, restApiService);
},
userModel: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.restApiService, 'restApiService');
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 53064fa..bea371f 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -20,12 +20,12 @@
import {ReportingService} from './gr-reporting/gr-reporting';
import {AuthService} from './gr-auth/gr-auth';
import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
+import {ChangeModel} from './change/change-model';
+import {ChecksModel} from './checks/checks-model';
import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
import {StorageService} from './storage/gr-storage';
import {UserModel} from './user/user-model';
-import {CommentsService} from './comments/comments-service';
+import {CommentsModel} from './comments/comments-model';
import {ShortcutsService} from './shortcuts/shortcuts-service';
import {BrowserModel} from './browser/browser-model';
import {ConfigModel} from './config/config-model';
@@ -36,9 +36,9 @@
eventEmitter: EventEmitterService;
authService: AuthService;
restApiService: RestApiService;
- changeService: ChangeService;
- commentsService: CommentsService;
- checksService: ChecksService;
+ changeModel: ChangeModel;
+ commentsModel: CommentsModel;
+ checksModel: ChecksModel;
jsApiService: JsApiService;
storageService: StorageService;
configModel: ConfigModel;
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 458f610..6a819f8 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -15,13 +15,22 @@
* limitations under the License.
*/
-import {PatchSetNum} from '../../types/common';
-import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {
+ combineLatest,
+ from,
+ fromEvent,
+ BehaviorSubject,
+ Observable,
+ Subscription,
+} from 'rxjs';
import {
map,
filter,
withLatestFrom,
distinctUntilChanged,
+ startWith,
+ switchMap,
} from 'rxjs/operators';
import {routerPatchNum$, routerState$} from '../router/router-model';
import {
@@ -30,6 +39,12 @@
} from '../../utils/patch-set-util';
import {ParsedChangeInfo} from '../../types/types';
+import {routerChangeNum$} from '../router/router-model';
+import {ChangeInfo} from '../../types/common';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {Finalizable} from '../registry';
+import {select} from '../../utils/observable-util';
+
export enum LoadingStatus {
NOT_LOADED = 'NOT_LOADED',
LOADING = 'LOADING',
@@ -58,133 +73,189 @@
loadingStatus: LoadingStatus.NOT_LOADED,
};
-const privateState$ = new BehaviorSubject(initialState);
+export class ChangeModel implements Finalizable {
+ private readonly privateState$ = new BehaviorSubject(initialState);
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...initialState});
-}
+ public readonly changeState$: Observable<ChangeState> =
+ this.privateState$.asObservable();
-export function _testOnly_setState(state: ChangeState) {
- privateState$.next(state);
-}
-
-export function _testOnly_getState() {
- return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const changeState$: Observable<ChangeState> = privateState$;
-
-// Must only be used by the change service or whatever is in control of this
-// model.
-export function updateStateChange(change?: ParsedChangeInfo) {
- const current = privateState$.getValue();
- // We want to make it easy for subscribers to react to change changes, so we
- // are explicitly emitting an additional `undefined` when the change number
- // changes. So if you are subscribed to the latestPatchsetNumber for example,
- // then you can rely on emissions even if the old and the new change have the
- // same latestPatchsetNumber.
- if (change !== undefined && current.change !== undefined) {
- if (change._number !== current.change._number) {
- privateState$.next({
- ...current,
- change: undefined,
- loadingStatus: LoadingStatus.NOT_LOADED,
- });
- }
- }
- privateState$.next({
- ...current,
- change,
- loadingStatus:
- change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
- });
-}
-
-export function updateStateLoading() {
- const current = privateState$.getValue();
- privateState$.next({
- ...current,
- loadingStatus:
- current.change === undefined
- ? LoadingStatus.LOADING
- : LoadingStatus.RELOADING,
- });
-}
-
-export function updateStatePath(diffPath?: string) {
- const current = privateState$.getValue();
- privateState$.next({...current, diffPath});
-}
-
-/**
- * If you depend on both, router and change state, then you want to filter out
- * inconsistent state, e.g. router changeNum already updated, change not yet
- * reset to undefined.
- */
-export const changeAndRouterConsistent$ = combineLatest([
- routerState$,
- changeState$,
-]).pipe(
- filter(([routerState, changeState]) => {
- const changeNum = changeState.change?._number;
- const routerChangeNum = routerState.changeNum;
- return changeNum === undefined || changeNum === routerChangeNum;
- }),
- distinctUntilChanged()
-);
-
-export const change$ = changeState$.pipe(
- map(changeState => changeState.change),
- distinctUntilChanged()
-);
-
-export const changeLoadingStatus$ = changeState$.pipe(
- map(changeState => changeState.loadingStatus),
- distinctUntilChanged()
-);
-
-export const diffPath$ = changeState$.pipe(
- map(changeState => changeState?.diffPath),
- distinctUntilChanged()
-);
-
-export const changeNum$ = change$.pipe(
- map(change => change?._number),
- distinctUntilChanged()
-);
-
-export const repo$ = change$.pipe(
- map(change => change?.project),
- distinctUntilChanged()
-);
-
-export const labels$ = change$.pipe(
- map(change => change?.labels),
- distinctUntilChanged()
-);
-
-export const latestPatchNum$ = change$.pipe(
- map(change => computeLatestPatchNum(computeAllPatchSets(change))),
- distinctUntilChanged()
-);
-
-/**
- * Emits the current patchset number. If the route does not define the current
- * patchset num, then this selector waits for the change to be defined and
- * returns the number of the latest patchset.
- *
- * Note that this selector can emit a patchNum without the change being
- * available!
- */
-export const currentPatchNum$: Observable<PatchSetNum | undefined> =
- changeAndRouterConsistent$.pipe(
- withLatestFrom(routerPatchNum$, latestPatchNum$),
- map(
- ([_, routerPatchNum, latestPatchNum]) => routerPatchNum || latestPatchNum
- ),
- distinctUntilChanged()
+ public readonly change$ = select(
+ this.privateState$,
+ changeState => changeState.change
);
+
+ public readonly changeLoadingStatus$ = select(
+ this.privateState$,
+ changeState => changeState.loadingStatus
+ );
+
+ public readonly diffPath$ = select(
+ this.privateState$,
+ changeState => changeState?.diffPath
+ );
+
+ public readonly changeNum$ = select(this.change$, change => change?._number);
+
+ public readonly repo$ = select(this.change$, change => change?.project);
+
+ public readonly labels$ = select(this.change$, change => change?.labels);
+
+ public readonly latestPatchNum$ = select(this.change$, change =>
+ computeLatestPatchNum(computeAllPatchSets(change))
+ );
+
+ /**
+ * Emits the current patchset number. If the route does not define the current
+ * patchset num, then this selector waits for the change to be defined and
+ * returns the number of the latest patchset.
+ *
+ * Note that this selector can emit a patchNum without the change being
+ * available!
+ */
+ public readonly currentPatchNum$: Observable<PatchSetNum | undefined> =
+ /**
+ * If you depend on both, router and change state, then you want to filter
+ * out inconsistent state, e.g. router changeNum already updated, change not
+ * yet reset to undefined.
+ */
+ combineLatest([routerState$, this.changeState$])
+ .pipe(
+ filter(([routerState, changeState]) => {
+ const changeNum = changeState.change?._number;
+ const routerChangeNum = routerState.changeNum;
+ return changeNum === undefined || changeNum === routerChangeNum;
+ }),
+ distinctUntilChanged()
+ )
+ .pipe(
+ withLatestFrom(routerPatchNum$, this.latestPatchNum$),
+ map(([_, routerPatchN, latestPatchN]) => routerPatchN || latestPatchN),
+ distinctUntilChanged()
+ );
+
+ private subscriptions: Subscription[] = [];
+
+ // For usage in `combineLatest` we need `startWith` such that reload$ has an
+ // initial value.
+ private readonly reload$: Observable<unknown> = fromEvent(
+ document,
+ 'reload'
+ ).pipe(startWith(undefined));
+
+ constructor(readonly restApiService: RestApiService) {
+ this.subscriptions = [
+ combineLatest([routerChangeNum$, this.reload$])
+ .pipe(
+ map(([changeNum, _]) => changeNum),
+ switchMap(changeNum => {
+ if (changeNum !== undefined) this.updateStateLoading(changeNum);
+ return from(this.restApiService.getChangeDetail(changeNum));
+ })
+ )
+ .subscribe(change => {
+ // The change service is currently a singleton, so we have to be
+ // careful to avoid situations where the application state is
+ // partially set for the old change where the user is coming from,
+ // and partially for the new change where the user is navigating to.
+ // So setting the change explicitly to undefined when the user
+ // moves away from diff and change pages (changeNum === undefined)
+ // helps with that.
+ this.updateStateChange(change ?? undefined);
+ }),
+ ];
+ }
+
+ finalize() {
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
+ }
+
+ // Temporary workaround until path is derived in the model itself.
+ updatePath(diffPath?: string) {
+ const current = this.getState();
+ this.setState({...current, diffPath});
+ }
+
+ /**
+ * Typically you would just subscribe to change$ yourself to get updates. But
+ * sometimes it is nice to also be able to get the current ChangeInfo on
+ * demand. So here it is for your convenience.
+ */
+ getChange() {
+ return this.getState().change;
+ }
+
+ /**
+ * Check whether there is no newer patch than the latest patch that was
+ * available when this change was loaded.
+ *
+ * @return A promise that yields true if the latest patch
+ * has been loaded, and false if a newer patch has been uploaded in the
+ * meantime. The promise is rejected on network error.
+ */
+ fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+ const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+ return this.restApiService.getChangeDetail(change._number).then(detail => {
+ if (!detail) {
+ const error = new Error('Change detail not found.');
+ return Promise.reject(error);
+ }
+ const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+ if (!actualLatest || !knownLatest) {
+ const error = new Error('Unable to check for latest patchset.');
+ return Promise.reject(error);
+ }
+ return {
+ isLatest: actualLatest <= knownLatest,
+ newStatus: change.status !== detail.status ? detail.status : null,
+ newMessages:
+ (change.messages || []).length < (detail.messages || []).length
+ ? detail.messages![detail.messages!.length - 1]
+ : undefined,
+ };
+ });
+ }
+
+ /**
+ * Called when change detail loading is initiated.
+ *
+ * If the change number matches the current change in the state, then
+ * this is a reload. If not, then we not just want to set the state to
+ * LOADING instead of RELOADING, but we also want to set the change to
+ * undefined right away. Otherwise components could see inconsistent state:
+ * a new change number, but an old change.
+ */
+ private updateStateLoading(changeNum: NumericChangeId) {
+ const current = this.getState();
+ const reloading = current.change?._number === changeNum;
+ this.setState({
+ ...current,
+ change: reloading ? current.change : undefined,
+ loadingStatus: reloading
+ ? LoadingStatus.RELOADING
+ : LoadingStatus.LOADING,
+ });
+ }
+
+ // Private but used in tests.
+ updateStateChange(change?: ParsedChangeInfo) {
+ const current = this.getState();
+ this.setState({
+ ...current,
+ change,
+ loadingStatus:
+ change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
+ });
+ }
+
+ getState(): ChangeState {
+ return this.privateState$.getValue();
+ }
+
+ // Private but used in tests
+ setState(state: ChangeState) {
+ this.privateState$.next(state);
+ }
+}
diff --git a/polygerrit-ui/app/services/change/change-model_test.ts b/polygerrit-ui/app/services/change/change-model_test.ts
new file mode 100644
index 0000000..099a11f
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-model_test.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+import {ChangeStatus} from '../../constants/constants';
+import '../../test/common-test-setup-karma';
+import {
+ createChange,
+ createChangeMessageInfo,
+ createRevision,
+} from '../../test/test-data-generators';
+import {mockPromise, stubRestApi, waitUntil} from '../../test/test-utils';
+import {CommitId, NumericChangeId, PatchSetNum} from '../../types/common';
+import {ParsedChangeInfo} from '../../types/types';
+import {getAppContext} from '../app-context';
+import {
+ GerritView,
+ _testOnly_setState as setRouterState,
+} from '../router/router-model';
+import {ChangeState, LoadingStatus} from './change-model';
+import {ChangeModel} from './change-model';
+
+suite('change service tests', () => {
+ let changeModel: ChangeModel;
+ let knownChange: ParsedChangeInfo;
+ const testCompleted = new Subject<void>();
+ setup(() => {
+ changeModel = new ChangeModel(getAppContext().restApiService);
+ knownChange = {
+ ...createChange(),
+ revisions: {
+ sha1: {
+ ...createRevision(1),
+ description: 'patch 1',
+ _number: 1 as PatchSetNum,
+ },
+ sha2: {
+ ...createRevision(2),
+ description: 'patch 2',
+ _number: 2 as PatchSetNum,
+ },
+ },
+ status: ChangeStatus.NEW,
+ current_revision: 'abc' as CommitId,
+ messages: [],
+ };
+ });
+
+ teardown(() => {
+ testCompleted.next();
+ changeModel.finalize();
+ });
+
+ test('load a change', async () => {
+ const promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
+ assert.equal(stub.callCount, 0);
+ assert.isUndefined(state?.change);
+
+ setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
+ assert.equal(stub.callCount, 1);
+ assert.isUndefined(state?.change);
+
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 1);
+ assert.equal(state?.change, knownChange);
+ });
+
+ test('reload a change', async () => {
+ // setting up a loaded change
+ const promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+ setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+ // Reloading same change
+ document.dispatchEvent(new CustomEvent('reload'));
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.RELOADING);
+ assert.equal(stub.callCount, 2);
+ assert.equal(state?.change, knownChange);
+
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 2);
+ assert.equal(state?.change, knownChange);
+ });
+
+ test('navigating to another change', async () => {
+ // setting up a loaded change
+ let promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+ setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+ // Navigating to other change
+
+ const otherChange: ParsedChangeInfo = {
+ ...knownChange,
+ _number: 123 as NumericChangeId,
+ };
+ promise = mockPromise<ParsedChangeInfo | undefined>();
+ setRouterState({view: GerritView.CHANGE, changeNum: otherChange._number});
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
+ assert.equal(stub.callCount, 2);
+ assert.isUndefined(state?.change);
+
+ promise.resolve(otherChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 2);
+ assert.equal(state?.change, otherChange);
+ });
+
+ test('navigating to dashboard', async () => {
+ // setting up a loaded change
+ let promise = mockPromise<ParsedChangeInfo | undefined>();
+ const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+ let state: ChangeState | undefined = {
+ loadingStatus: LoadingStatus.NOT_LOADED,
+ };
+ changeModel.changeState$
+ .pipe(takeUntil(testCompleted))
+ .subscribe(s => (state = s));
+ setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+ promise.resolve(knownChange);
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+
+ // Navigating to dashboard
+
+ promise = mockPromise<ParsedChangeInfo | undefined>();
+ promise.resolve(undefined);
+ setRouterState({view: GerritView.DASHBOARD, changeNum: undefined});
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
+ assert.equal(stub.callCount, 2);
+ assert.isUndefined(state?.change);
+
+ // Navigating back from dashboard to change page
+
+ promise = mockPromise<ParsedChangeInfo | undefined>();
+ promise.resolve(knownChange);
+ setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
+ await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 3);
+ assert.equal(state?.change, knownChange);
+ });
+
+ test('changeModel.fetchChangeUpdates on latest', async () => {
+ stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isTrue(result.isLatest);
+ assert.isNotOk(result.newStatus);
+ assert.isNotOk(result.newMessages);
+ });
+
+ test('changeModel.fetchChangeUpdates not on latest', async () => {
+ const actualChange = {
+ ...knownChange,
+ revisions: {
+ ...knownChange.revisions,
+ sha3: {
+ ...createRevision(3),
+ description: 'patch 3',
+ _number: 3 as PatchSetNum,
+ },
+ },
+ };
+ stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isFalse(result.isLatest);
+ assert.isNotOk(result.newStatus);
+ assert.isNotOk(result.newMessages);
+ });
+
+ test('changeModel.fetchChangeUpdates new status', async () => {
+ const actualChange = {
+ ...knownChange,
+ status: ChangeStatus.MERGED,
+ };
+ stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isTrue(result.isLatest);
+ assert.equal(result.newStatus, ChangeStatus.MERGED);
+ assert.isNotOk(result.newMessages);
+ });
+
+ test('changeModel.fetchChangeUpdates new messages', async () => {
+ const actualChange = {
+ ...knownChange,
+ messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
+ };
+ stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ const result = await changeModel.fetchChangeUpdates(knownChange);
+ assert.isTrue(result.isLatest);
+ assert.isNotOk(result.newStatus);
+ assert.deepEqual(result.newMessages, {
+ ...createChangeMessageInfo(),
+ message: 'blah blah',
+ });
+ });
+});
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
deleted file mode 100644
index c1b8c9b..0000000
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {combineLatest, from, fromEvent, Observable, Subscription} from 'rxjs';
-import {map, startWith, switchMap} from 'rxjs/operators';
-import {routerChangeNum$} from '../router/router-model';
-import {
- change$,
- updateStateChange,
- updateStateLoading,
- updateStatePath,
-} from './change-model';
-import {ParsedChangeInfo} from '../../types/types';
-import {ChangeInfo} from '../../types/common';
-import {
- computeAllPatchSets,
- computeLatestPatchNum,
-} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {Finalizable} from '../registry';
-
-export class ChangeService implements Finalizable {
- private change?: ParsedChangeInfo;
-
- private readonly subscriptions: Subscription[] = [];
-
- // For usage in `combineLatest` we need `startWith` such that reload$ has an
- // initial value.
- private readonly reload$: Observable<unknown> = fromEvent(
- document,
- 'reload'
- ).pipe(startWith(undefined));
-
- constructor(readonly restApiService: RestApiService) {
- this.subscriptions.push(
- combineLatest([routerChangeNum$, this.reload$])
- .pipe(
- map(([changeNum, _]) => changeNum),
- switchMap(changeNum => {
- if (changeNum !== undefined) updateStateLoading();
- return from(this.restApiService.getChangeDetail(changeNum));
- })
- )
- .subscribe(change => {
- // The change service is currently a singleton, so we have to be
- // careful to avoid situations where the application state is
- // partially set for the old change where the user is coming from,
- // and partially for the new change where the user is navigating to.
- // So setting the change explicitly to undefined when the user
- // moves away from diff and change pages (changeNum === undefined)
- // helps with that.
- updateStateChange(change ?? undefined);
- })
- );
- this.subscriptions.push(
- change$.subscribe(change => {
- this.change = change;
- })
- );
- }
-
- finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-
- // Temporary workaround until path is derived in the model itself.
- updatePath(path?: string) {
- updateStatePath(path);
- }
-
- /**
- * Typically you would just subscribe to change$ yourself to get updates. But
- * sometimes it is nice to also be able to get the current ChangeInfo on
- * demand. So here it is for your convenience.
- */
- getChange() {
- return this.change;
- }
-
- /**
- * Check whether there is no newer patch than the latest patch that was
- * available when this change was loaded.
- *
- * @return A promise that yields true if the latest patch
- * has been loaded, and false if a newer patch has been uploaded in the
- * meantime. The promise is rejected on network error.
- */
- fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
- const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
- return this.restApiService.getChangeDetail(change._number).then(detail => {
- if (!detail) {
- const error = new Error('Change detail not found.');
- return Promise.reject(error);
- }
- const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
- if (!actualLatest || !knownLatest) {
- const error = new Error('Unable to check for latest patchset.');
- return Promise.reject(error);
- }
- return {
- isLatest: actualLatest <= knownLatest,
- newStatus: change.status !== detail.status ? detail.status : null,
- newMessages:
- (change.messages || []).length < (detail.messages || []).length
- ? detail.messages![detail.messages!.length - 1]
- : undefined,
- };
- });
- }
-}
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
deleted file mode 100644
index f8aeb46..0000000
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {ChangeStatus} from '../../constants/constants';
-import '../../test/common-test-setup-karma';
-import {
- createChange,
- createChangeMessageInfo,
- createRevision,
-} from '../../test/test-data-generators';
-import {mockPromise, stubRestApi, waitUntil} from '../../test/test-utils';
-import {CommitId, PatchSetNum} from '../../types/common';
-import {ParsedChangeInfo} from '../../types/types';
-import {getAppContext} from '../app-context';
-import {
- GerritView,
- _testOnly_setState as setRouterState,
-} from '../router/router-model';
-import {ChangeState, changeState$, LoadingStatus} from './change-model';
-import {ChangeService} from './change-service';
-
-suite('change service tests', () => {
- let changeService: ChangeService;
- let knownChange: ParsedChangeInfo;
- setup(() => {
- changeService = new ChangeService(getAppContext().restApiService);
- knownChange = {
- ...createChange(),
- revisions: {
- sha1: {
- ...createRevision(1),
- description: 'patch 1',
- _number: 1 as PatchSetNum,
- },
- sha2: {
- ...createRevision(2),
- description: 'patch 2',
- _number: 2 as PatchSetNum,
- },
- },
- status: ChangeStatus.NEW,
- current_revision: 'abc' as CommitId,
- messages: [],
- };
- });
-
- teardown(() => {
- changeService.finalize();
- });
-
- test('change not loaded, loading, reloading, ...', async () => {
- let promise = mockPromise<ParsedChangeInfo | undefined>();
- const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
- let state: ChangeState | undefined = {
- loadingStatus: LoadingStatus.NOT_LOADED,
- };
- changeState$.subscribe(s => (state = s));
-
- await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
- assert.equal(stub.callCount, 0);
- assert.isUndefined(state?.change);
-
- setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
- await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
- assert.equal(stub.callCount, 1);
- assert.isUndefined(state?.change);
-
- promise.resolve(knownChange);
- await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
- assert.equal(stub.callCount, 1);
- assert.equal(state?.change, knownChange);
-
- promise = mockPromise<ParsedChangeInfo | undefined>();
- document.dispatchEvent(new CustomEvent('reload'));
- await waitUntil(() => state?.loadingStatus === LoadingStatus.RELOADING);
- assert.equal(stub.callCount, 2);
- assert.equal(state?.change, knownChange);
-
- promise.resolve(knownChange);
- await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
- assert.equal(stub.callCount, 2);
- assert.equal(state?.change, knownChange);
-
- promise = mockPromise<ParsedChangeInfo | undefined>();
- promise.resolve(undefined);
- setRouterState({view: GerritView.DASHBOARD, changeNum: undefined});
- await waitUntil(() => state?.loadingStatus === LoadingStatus.NOT_LOADED);
- assert.equal(stub.callCount, 3);
- assert.isUndefined(state?.change);
-
- promise = mockPromise<ParsedChangeInfo | undefined>();
- setRouterState({view: GerritView.CHANGE, changeNum: knownChange._number});
- await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADING);
- assert.equal(stub.callCount, 4);
- assert.isUndefined(state?.change);
-
- promise.resolve(knownChange);
- await waitUntil(() => state?.loadingStatus === LoadingStatus.LOADED);
- assert.equal(stub.callCount, 4);
- assert.equal(state?.change, knownChange);
- });
-
- test('changeService.fetchChangeUpdates on latest', async () => {
- stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isTrue(result.isLatest);
- assert.isNotOk(result.newStatus);
- assert.isNotOk(result.newMessages);
- });
-
- test('changeService.fetchChangeUpdates not on latest', async () => {
- const actualChange = {
- ...knownChange,
- revisions: {
- ...knownChange.revisions,
- sha3: {
- ...createRevision(3),
- description: 'patch 3',
- _number: 3 as PatchSetNum,
- },
- },
- };
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isFalse(result.isLatest);
- assert.isNotOk(result.newStatus);
- assert.isNotOk(result.newMessages);
- });
-
- test('changeService.fetchChangeUpdates new status', async () => {
- const actualChange = {
- ...knownChange,
- status: ChangeStatus.MERGED,
- };
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isTrue(result.isLatest);
- assert.equal(result.newStatus, ChangeStatus.MERGED);
- assert.isNotOk(result.newMessages);
- });
-
- test('changeService.fetchChangeUpdates new messages', async () => {
- const actualChange = {
- ...knownChange,
- messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
- };
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
- const result = await changeService.fetchChangeUpdates(knownChange);
- assert.isTrue(result.isLatest);
- assert.isNotOk(result.newStatus);
- assert.deepEqual(result.newMessages, {
- ...createChangeMessageInfo(),
- message: 'blah blah',
- });
- });
-});
diff --git a/polygerrit-ui/app/services/checks/checks-fakes.ts b/polygerrit-ui/app/services/checks/checks-fakes.ts
new file mode 100644
index 0000000..09cd2e7
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-fakes.ts
@@ -0,0 +1,418 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ Action,
+ Category,
+ Link,
+ LinkIcon,
+ RunStatus,
+ TagColor,
+} from '../../api/checks';
+import {CheckRun} from './checks-model';
+
+// TODO(brohlfs): Eventually these fakes should be removed. But they have proven
+// to be super convenient for testing, debugging and demoing, so I would like to
+// keep them around for a few quarters. Maybe remove by EOY 2022?
+
+export const fakeRun0: CheckRun = {
+ pluginName: 'f0',
+ internalRunId: 'f0',
+ checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
+ labelName: 'Presubmit',
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f0r0',
+ category: Category.ERROR,
+ summary: 'I would like to point out this error: 1 is not equal to 2!',
+ links: [
+ {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+ ],
+ tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
+ },
+ {
+ internalResultId: 'f0r1',
+ category: Category.ERROR,
+ summary: 'Running the mighty test has failed by crashing.',
+ message: 'Btw, 1 is also not equal to 3. Did you know?',
+ actions: [
+ {
+ name: 'Ignore',
+ tooltip: 'Ignore this result',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
+ },
+ {
+ name: 'Flag',
+ tooltip: 'Flag this result as totally absolutely really not useful',
+ primary: true,
+ disabled: true,
+ callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
+ },
+ {
+ name: 'Upload',
+ tooltip: 'Upload the result to the super cloud.',
+ primary: false,
+ callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
+ },
+ ],
+ tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
+ links: [
+ {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+ {
+ primary: true,
+ url: 'https://google.com',
+ icon: LinkIcon.DOWNLOAD_MOBILE,
+ },
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
+ ],
+ },
+ ],
+ status: RunStatus.COMPLETED,
+};
+
+export const fakeRun1: CheckRun = {
+ pluginName: 'f1',
+ internalRunId: 'f1',
+ checkName: 'FAKE Super Check',
+ statusLink: 'https://www.google.com/',
+ patchset: 1,
+ labelName: 'Verified',
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f1r0',
+ category: Category.WARNING,
+ summary: 'We think that you could improve this.',
+ message: `There is a lot to be said. A lot. I say, a lot.\n
+ So please keep reading.`,
+ tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+ codePointers: [
+ {
+ path: '/COMMIT_MSG',
+ range: {
+ start_line: 10,
+ start_character: 0,
+ end_line: 10,
+ end_character: 0,
+ },
+ },
+ {
+ path: 'polygerrit-ui/app/api/checks.ts',
+ range: {
+ start_line: 5,
+ start_character: 0,
+ end_line: 7,
+ end_character: 0,
+ },
+ },
+ ],
+ links: [
+ {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+ {
+ primary: true,
+ url: 'https://google.com',
+ icon: LinkIcon.DOWNLOAD_MOBILE,
+ },
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {
+ primary: false,
+ url: 'https://google.com',
+ tooltip: 'look at this',
+ icon: LinkIcon.IMAGE,
+ },
+ {
+ primary: false,
+ url: 'https://google.com',
+ tooltip: 'not at this',
+ icon: LinkIcon.IMAGE,
+ },
+ ],
+ },
+ ],
+ status: RunStatus.RUNNING,
+};
+
+export const fakeRun2: CheckRun = {
+ pluginName: 'f2',
+ internalRunId: 'f2',
+ checkName: 'FAKE Mega Analysis',
+ statusDescription: 'This run is nearly completed, but not quite.',
+ statusLink: 'https://www.google.com/',
+ checkDescription:
+ 'From what the title says you can tell that this check analyses.',
+ checkLink: 'https://www.google.com/',
+ scheduledTimestamp: new Date('2021-04-01T03:14:15'),
+ startedTimestamp: new Date('2021-04-01T04:24:25'),
+ finishedTimestamp: new Date('2021-04-01T04:44:44'),
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ actions: [
+ {
+ name: 'Re-Run',
+ tooltip: 'More powerful run than before',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+ },
+ {
+ name: 'Monetize',
+ primary: true,
+ disabled: true,
+ callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
+ },
+ {
+ name: 'Delete',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
+ },
+ ],
+ results: [
+ {
+ internalResultId: 'f2r0',
+ category: Category.INFO,
+ summary: 'This is looking a bit too large.',
+ message: `We are still looking into how large exactly. Stay tuned.
+And have a look at https://www.google.com!
+
+Or have a look at change 30000.
+Example code:
+ const constable = '';
+ var variable = '';`,
+ tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
+ },
+ ],
+ status: RunStatus.COMPLETED,
+};
+
+export const fakeRun3: CheckRun = {
+ pluginName: 'f3',
+ internalRunId: 'f3',
+ checkName: 'FAKE Critical Observations',
+ status: RunStatus.RUNNABLE,
+ isSingleAttempt: true,
+ isLatestAttempt: true,
+ attemptDetails: [],
+};
+
+export const fakeRun4_1: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.RUNNABLE,
+ attempt: 1,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+};
+
+export const fakeRun4_2: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.COMPLETED,
+ attempt: 2,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f42r0',
+ category: Category.INFO,
+ summary: 'Please eliminate all the TODOs!',
+ },
+ ],
+};
+
+export const fakeRun4_3: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.COMPLETED,
+ attempt: 3,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f43r0',
+ category: Category.ERROR,
+ summary: 'Without eliminating all the TODOs your change will break!',
+ },
+ ],
+};
+
+export const fakeRun4_4: CheckRun = {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ checkDescription: 'Shows you the possible eliminations.',
+ checkLink: 'https://www.google.com',
+ status: RunStatus.COMPLETED,
+ statusDescription: 'Everything was eliminated already.',
+ statusLink: 'https://www.google.com',
+ attempt: 40,
+ scheduledTimestamp: new Date('2021-04-02T03:14:15'),
+ startedTimestamp: new Date('2021-04-02T04:24:25'),
+ finishedTimestamp: new Date('2021-04-02T04:25:44'),
+ isSingleAttempt: false,
+ isLatestAttempt: true,
+ attemptDetails: [],
+ results: [
+ {
+ internalResultId: 'f44r0',
+ category: Category.INFO,
+ summary: 'Dont be afraid. All TODOs will be eliminated.',
+ actions: [
+ {
+ name: 'Re-Run',
+ tooltip: 'More powerful run than before with a long tooltip, really.',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+ },
+ ],
+ },
+ ],
+ actions: [
+ {
+ name: 'Re-Run',
+ tooltip: 'small',
+ primary: true,
+ callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+ },
+ ],
+};
+
+export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
+ const runs: CheckRun[] = [];
+ for (let i = from; i < to; i++) {
+ runs.push(fakeRun4CreateAttempt(i));
+ }
+ return runs;
+}
+
+export function fakeRun4CreateAttempt(attempt: number): CheckRun {
+ return {
+ pluginName: 'f4',
+ internalRunId: 'f4',
+ checkName: 'FAKE Elimination Long Long Long Long Long',
+ status: RunStatus.COMPLETED,
+ attempt,
+ isSingleAttempt: false,
+ isLatestAttempt: false,
+ attemptDetails: [],
+ results:
+ attempt % 2 === 0
+ ? [
+ {
+ internalResultId: 'f43r0',
+ category: Category.ERROR,
+ summary:
+ 'Without eliminating all the TODOs your change will break!',
+ },
+ ]
+ : [],
+ };
+}
+
+export const fakeRun4Att = [
+ fakeRun4_1,
+ fakeRun4_2,
+ fakeRun4_3,
+ ...fakeRun4CreateAttempts(5, 40),
+ fakeRun4_4,
+];
+
+export const fakeActions: Action[] = [
+ {
+ name: 'Fake Action 1',
+ primary: true,
+ disabled: true,
+ tooltip: 'Tooltip for Fake Action 1',
+ callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
+ },
+ {
+ name: 'Fake Action 2',
+ primary: false,
+ disabled: true,
+ tooltip: 'Tooltip for Fake Action 2',
+ callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
+ },
+ {
+ name: 'Fake Action 3',
+ summary: true,
+ primary: false,
+ tooltip: 'Tooltip for Fake Action 3',
+ callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
+ },
+];
+
+export const fakeLinks: Link[] = [
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Bug Report 1',
+ icon: LinkIcon.REPORT_BUG,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Bug Report 2',
+ icon: LinkIcon.REPORT_BUG,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Link 1',
+ icon: LinkIcon.EXTERNAL,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: false,
+ tooltip: 'Fake Link 2',
+ icon: LinkIcon.EXTERNAL,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Code Link',
+ icon: LinkIcon.CODE,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Image Link',
+ icon: LinkIcon.IMAGE,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Help Link',
+ icon: LinkIcon.HELP_PAGE,
+ },
+];
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index bb6809f..4024747 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -14,23 +14,48 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-import {BehaviorSubject, Observable} from 'rxjs';
+import {AttemptDetail, createAttemptMap} from './checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {select} from '../../utils/observable-util';
+import {Finalizable} from '../registry';
+import {
+ BehaviorSubject,
+ combineLatest,
+ from,
+ Observable,
+ of,
+ Subject,
+ Subscription,
+ timer,
+} from 'rxjs';
+import {
+ catchError,
+ filter,
+ switchMap,
+ takeUntil,
+ takeWhile,
+ throttleTime,
+ withLatestFrom,
+} from 'rxjs/operators';
import {
Action,
- Category,
CheckResult as CheckResultApi,
CheckRun as CheckRunApi,
Link,
- LinkIcon,
- RunStatus,
- TagColor,
+ ChangeData,
+ ChecksApiConfig,
+ ChecksProvider,
+ FetchResponse,
+ ResponseCode,
} from '../../api/checks';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {PatchSetNumber} from '../../types/common';
-import {AttemptDetail, createAttemptMap} from './checks-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {deepEqual} from '../../utils/deep-util';
+import {ChangeModel} from '../change/change-model';
+import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
+import {getCurrentRevision} from '../../utils/change-util';
+import {getShaByPatchNum} from '../../utils/patch-set-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
+import {routerPatchNum$} from '../router/router-model';
+import {Execution} from '../../constants/reporting';
+import {fireAlert, fireEvent} from '../../utils/event-util';
/**
* The checks model maintains the state of checks for two patchsets: the latest
@@ -83,7 +108,7 @@
// properties. So you can just combine them with {...run, ...result}.
export type RunResult = CheckRun & CheckResult;
-interface ChecksProviderState {
+export interface ChecksProviderState {
pluginName: string;
loading: boolean;
/**
@@ -121,117 +146,101 @@
};
}
-const initialState: ChecksState = {
- pluginStateLatest: {},
- pluginStateSelected: {},
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...initialState});
-}
-
-export function _testOnly_setState(state: ChecksState) {
- privateState$.next(state);
-}
-
-export function _testOnly_getState() {
- return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const checksState$: Observable<ChecksState> = privateState$;
-
-export const checksSelectedPatchsetNumber$ = checksState$.pipe(
- map(state => state.patchsetNumberSelected),
- distinctUntilChanged()
-);
-
-export const checksLatest$ = checksState$.pipe(
- map(state => state.pluginStateLatest),
- distinctUntilChanged()
-);
-
-export const checksSelected$ = checksState$.pipe(
- map(state =>
- state.patchsetNumberSelected
- ? state.pluginStateSelected
- : state.pluginStateLatest
- ),
- distinctUntilChanged()
-);
-
-export const aPluginHasRegistered$ = checksLatest$.pipe(
- map(state => Object.keys(state).length > 0),
- distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingFirstTime$ = checksLatest$.pipe(
- map(state =>
- Object.values(state).some(
- provider => provider.loading && provider.firstTimeLoad
- )
- ),
- distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
- map(state =>
- Object.values(state).some(providerState => providerState.loading)
- ),
- distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
- map(state =>
- Object.values(state).some(providerState => providerState.loading)
- ),
- distinctUntilChanged()
-);
-
-export const errorMessageLatest$ = checksLatest$.pipe(
- map(
- state =>
- Object.values(state).find(
- providerState => providerState.errorMessage !== undefined
- )?.errorMessage
- ),
- distinctUntilChanged()
-);
-
export interface ErrorMessages {
/* Maps plugin name to error message. */
[name: string]: string;
}
-export const errorMessagesLatest$ = checksLatest$.pipe(
- map(state => {
+export class ChecksModel implements Finalizable {
+ private readonly providers: {[name: string]: ChecksProvider} = {};
+
+ private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
+
+ private checkToPluginMap = new Map<string, string>();
+
+ private changeNum?: NumericChangeId;
+
+ private latestPatchNum?: PatchSetNumber;
+
+ private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+ private readonly reloadListener: () => void;
+
+ private readonly visibilityChangeListener: () => void;
+
+ private subscriptions: Subscription[] = [];
+
+ private readonly privateState$ = new BehaviorSubject<ChecksState>({
+ pluginStateLatest: {},
+ pluginStateSelected: {},
+ });
+
+ public checksState$: Observable<ChecksState> =
+ this.privateState$.asObservable();
+
+ public checksSelectedPatchsetNumber$ = select(
+ this.checksState$,
+ state => state.patchsetNumberSelected
+ );
+
+ public checksLatest$ = select(
+ this.checksState$,
+ state => state.pluginStateLatest
+ );
+
+ public checksSelected$ = select(this.checksState$, state =>
+ state.patchsetNumberSelected
+ ? state.pluginStateSelected
+ : state.pluginStateLatest
+ );
+
+ public aPluginHasRegistered$ = select(
+ this.checksLatest$,
+ state => Object.keys(state).length > 0
+ );
+
+ public someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state =>
+ Object.values(state).some(
+ provider => provider.loading && provider.firstTimeLoad
+ )
+ );
+
+ public someProvidersAreLoadingLatest$ = select(this.checksLatest$, state =>
+ Object.values(state).some(providerState => providerState.loading)
+ );
+
+ public someProvidersAreLoadingSelected$ = select(
+ this.checksSelected$,
+ state => Object.values(state).some(providerState => providerState.loading)
+ );
+
+ public errorMessageLatest$ = select(
+ this.checksLatest$,
+
+ state =>
+ Object.values(state).find(
+ providerState => providerState.errorMessage !== undefined
+ )?.errorMessage
+ );
+
+ public errorMessagesLatest$ = select(this.checksLatest$, state => {
const errorMessages: ErrorMessages = {};
for (const providerState of Object.values(state)) {
if (providerState.errorMessage === undefined) continue;
errorMessages[providerState.pluginName] = providerState.errorMessage;
}
return errorMessages;
- }),
- distinctUntilChanged(deepEqual)
-);
+ });
-export const loginCallbackLatest$ = checksLatest$.pipe(
- map(
+ public loginCallbackLatest$ = select(
+ this.checksLatest$,
state =>
Object.values(state).find(
providerState => providerState.loginCallback !== undefined
)?.loginCallback
- ),
- distinctUntilChanged()
-);
+ );
-export const topLevelActionsLatest$ = checksLatest$.pipe(
- map(state =>
+ public topLevelActionsLatest$ = select(this.checksLatest$, state =>
Object.values(state).reduce(
(allActions: Action[], providerState: ChecksProviderState) => [
...allActions,
@@ -239,12 +248,9 @@
],
[]
)
- ),
- distinctUntilChanged<Action[]>(deepEqual)
-);
+ );
-export const topLevelActionsSelected$ = checksSelected$.pipe(
- map(state =>
+ public topLevelActionsSelected$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allActions: Action[], providerState: ChecksProviderState) => [
...allActions,
@@ -252,12 +258,9 @@
],
[]
)
- ),
- distinctUntilChanged<Action[]>(deepEqual)
-);
+ );
-export const topLevelLinksSelected$ = checksSelected$.pipe(
- map(state =>
+ public topLevelLinksSelected$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allLinks: Link[], providerState: ChecksProviderState) => [
...allLinks,
@@ -265,12 +268,9 @@
],
[]
)
- ),
- distinctUntilChanged<Link[]>(deepEqual)
-);
+ );
-export const allRunsLatestPatchset$ = checksLatest$.pipe(
- map(state =>
+ public allRunsLatestPatchset$ = select(this.checksLatest$, state =>
Object.values(state).reduce(
(allRuns: CheckRun[], providerState: ChecksProviderState) => [
...allRuns,
@@ -278,12 +278,9 @@
],
[]
)
- ),
- distinctUntilChanged<CheckRun[]>(deepEqual)
-);
+ );
-export const allRunsSelectedPatchset$ = checksSelected$.pipe(
- map(state =>
+ public allRunsSelectedPatchset$ = select(this.checksSelected$, state =>
Object.values(state).reduce(
(allRuns: CheckRun[], providerState: ChecksProviderState) => [
...allRuns,
@@ -291,16 +288,14 @@
],
[]
)
- ),
- distinctUntilChanged<CheckRun[]>(deepEqual)
-);
+ );
-export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
- map(runs => runs.filter(run => run.isLatestAttempt))
-);
+ public allRunsLatestPatchsetLatestAttempt$ = select(
+ this.allRunsLatestPatchset$,
+ runs => runs.filter(run => run.isLatestAttempt)
+ );
-export const checkToPluginMap$ = checksLatest$.pipe(
- map(state => {
+ public checkToPluginMap$ = select(this.checksLatest$, state => {
const map = new Map<string, string>();
for (const [pluginName, providerState] of Object.entries(state)) {
for (const run of providerState.runs) {
@@ -308,11 +303,9 @@
}
}
return map;
- })
-);
+ });
-export const allResultsSelected$ = checksSelected$.pipe(
- map(state =>
+ public allResultsSelected$ = select(this.checksSelected$, state =>
Object.values(state)
.reduce(
(allResults: CheckResult[], providerState: ChecksProviderState) => [
@@ -326,569 +319,440 @@
[]
)
.filter(r => r !== undefined)
- )
-);
+ );
-// Must only be used by the checks service or whatever is in control of this
-// model.
-export function updateStateSetProvider(
- pluginName: string,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- pluginName,
- loading: false,
- firstTimeLoad: true,
- runs: [],
- actions: [],
- links: [],
- };
- privateState$.next(nextState);
-}
-
-// TODO(brohlfs): Remove all fake runs once the Checks UI is fully launched.
-// They are just making it easier to develop the UI and always see all the
-// different types/states of runs and results.
-
-export const fakeRun0: CheckRun = {
- pluginName: 'f0',
- internalRunId: 'f0',
- checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
- labelName: 'Presubmit',
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f0r0',
- category: Category.ERROR,
- summary: 'I would like to point out this error: 1 is not equal to 2!',
- links: [
- {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
- ],
- tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
- },
- {
- internalResultId: 'f0r1',
- category: Category.ERROR,
- summary: 'Running the mighty test has failed by crashing.',
- message: 'Btw, 1 is also not equal to 3. Did you know?',
- actions: [
- {
- name: 'Ignore',
- tooltip: 'Ignore this result',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
- },
- {
- name: 'Flag',
- tooltip: 'Flag this result as totally absolutely really not useful',
- primary: true,
- disabled: true,
- callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
- },
- {
- name: 'Upload',
- tooltip: 'Upload the result to the super cloud.',
- primary: false,
- callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
- },
- ],
- tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
- links: [
- {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
- {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
- {
- primary: true,
- url: 'https://google.com',
- icon: LinkIcon.DOWNLOAD_MOBILE,
- },
- {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
- {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
- {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
- ],
- },
- ],
- status: RunStatus.COMPLETED,
-};
-
-export const fakeRun1: CheckRun = {
- pluginName: 'f1',
- internalRunId: 'f1',
- checkName: 'FAKE Super Check',
- statusLink: 'https://www.google.com/',
- patchset: 1,
- labelName: 'Verified',
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f1r0',
- category: Category.WARNING,
- summary: 'We think that you could improve this.',
- message: `There is a lot to be said. A lot. I say, a lot.\n
- So please keep reading.`,
- tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
- codePointers: [
- {
- path: '/COMMIT_MSG',
- range: {
- start_line: 10,
- start_character: 0,
- end_line: 10,
- end_character: 0,
- },
- },
- {
- path: 'polygerrit-ui/app/api/checks.ts',
- range: {
- start_line: 5,
- start_character: 0,
- end_line: 7,
- end_character: 0,
- },
- },
- ],
- links: [
- {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
- {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
- {
- primary: true,
- url: 'https://google.com',
- icon: LinkIcon.DOWNLOAD_MOBILE,
- },
- {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
- {
- primary: false,
- url: 'https://google.com',
- tooltip: 'look at this',
- icon: LinkIcon.IMAGE,
- },
- {
- primary: false,
- url: 'https://google.com',
- tooltip: 'not at this',
- icon: LinkIcon.IMAGE,
- },
- ],
- },
- ],
- status: RunStatus.RUNNING,
-};
-
-export const fakeRun2: CheckRun = {
- pluginName: 'f2',
- internalRunId: 'f2',
- checkName: 'FAKE Mega Analysis',
- statusDescription: 'This run is nearly completed, but not quite.',
- statusLink: 'https://www.google.com/',
- checkDescription:
- 'From what the title says you can tell that this check analyses.',
- checkLink: 'https://www.google.com/',
- scheduledTimestamp: new Date('2021-04-01T03:14:15'),
- startedTimestamp: new Date('2021-04-01T04:24:25'),
- finishedTimestamp: new Date('2021-04-01T04:44:44'),
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
- actions: [
- {
- name: 'Re-Run',
- tooltip: 'More powerful run than before',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
- },
- {
- name: 'Monetize',
- primary: true,
- disabled: true,
- callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
- },
- {
- name: 'Delete',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
- },
- ],
- results: [
- {
- internalResultId: 'f2r0',
- category: Category.INFO,
- summary: 'This is looking a bit too large.',
- message: `We are still looking into how large exactly. Stay tuned.
-And have a look at https://www.google.com!
-
-Or have a look at change 30000.
-Example code:
- const constable = '';
- var variable = '';`,
- tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
- },
- ],
- status: RunStatus.COMPLETED,
-};
-
-export const fakeRun3: CheckRun = {
- pluginName: 'f3',
- internalRunId: 'f3',
- checkName: 'FAKE Critical Observations',
- status: RunStatus.RUNNABLE,
- isSingleAttempt: true,
- isLatestAttempt: true,
- attemptDetails: [],
-};
-
-export const fakeRun4_1: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.RUNNABLE,
- attempt: 1,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
-};
-
-export const fakeRun4_2: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.COMPLETED,
- attempt: 2,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f42r0',
- category: Category.INFO,
- summary: 'Please eliminate all the TODOs!',
- },
- ],
-};
-
-export const fakeRun4_3: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.COMPLETED,
- attempt: 3,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f43r0',
- category: Category.ERROR,
- summary: 'Without eliminating all the TODOs your change will break!',
- },
- ],
-};
-
-export const fakeRun4_4: CheckRun = {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- checkDescription: 'Shows you the possible eliminations.',
- checkLink: 'https://www.google.com',
- status: RunStatus.COMPLETED,
- statusDescription: 'Everything was eliminated already.',
- statusLink: 'https://www.google.com',
- attempt: 40,
- scheduledTimestamp: new Date('2021-04-02T03:14:15'),
- startedTimestamp: new Date('2021-04-02T04:24:25'),
- finishedTimestamp: new Date('2021-04-02T04:25:44'),
- isSingleAttempt: false,
- isLatestAttempt: true,
- attemptDetails: [],
- results: [
- {
- internalResultId: 'f44r0',
- category: Category.INFO,
- summary: 'Dont be afraid. All TODOs will be eliminated.',
- actions: [
- {
- name: 'Re-Run',
- tooltip: 'More powerful run than before with a long tooltip, really.',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
- },
- ],
- },
- ],
- actions: [
- {
- name: 'Re-Run',
- tooltip: 'small',
- primary: true,
- callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
- },
- ],
-};
-
-export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
- const runs: CheckRun[] = [];
- for (let i = from; i < to; i++) {
- runs.push(fakeRun4CreateAttempt(i));
+ constructor(
+ readonly changeModel: ChangeModel,
+ readonly reporting: ReportingService
+ ) {
+ this.subscriptions = [
+ this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+ this.checkToPluginMap$.subscribe(map => {
+ this.checkToPluginMap = map;
+ }),
+ combineLatest([
+ routerPatchNum$,
+ this.changeModel.latestPatchNum$,
+ ]).subscribe(([routerPs, latestPs]) => {
+ this.latestPatchNum = latestPs;
+ if (latestPs === undefined) {
+ this.setPatchset(undefined);
+ } else if (typeof routerPs === 'number') {
+ this.setPatchset(routerPs);
+ } else {
+ this.setPatchset(latestPs);
+ }
+ }),
+ ];
+ this.visibilityChangeListener = () => {
+ this.documentVisibilityChange$.next(undefined);
+ };
+ document.addEventListener(
+ 'visibilitychange',
+ this.visibilityChangeListener
+ );
+ this.reloadListener = () => this.reloadAll();
+ document.addEventListener('reload', this.reloadListener);
}
- return runs;
-}
-export function fakeRun4CreateAttempt(attempt: number): CheckRun {
- return {
- pluginName: 'f4',
- internalRunId: 'f4',
- checkName: 'FAKE Elimination Long Long Long Long Long',
- status: RunStatus.COMPLETED,
- attempt,
- isSingleAttempt: false,
- isLatestAttempt: false,
- attemptDetails: [],
- results:
- attempt % 2 === 0
- ? [
- {
- internalResultId: 'f43r0',
- category: Category.ERROR,
- summary:
- 'Without eliminating all the TODOs your change will break!',
- },
- ]
- : [],
- };
-}
-
-export const fakeRun4Att = [
- fakeRun4_1,
- fakeRun4_2,
- fakeRun4_3,
- ...fakeRun4CreateAttempts(5, 40),
- fakeRun4_4,
-];
-
-export const fakeActions: Action[] = [
- {
- name: 'Fake Action 1',
- primary: true,
- disabled: true,
- tooltip: 'Tooltip for Fake Action 1',
- callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
- },
- {
- name: 'Fake Action 2',
- primary: false,
- disabled: true,
- tooltip: 'Tooltip for Fake Action 2',
- callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
- },
- {
- name: 'Fake Action 3',
- summary: true,
- primary: false,
- tooltip: 'Tooltip for Fake Action 3',
- callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
- },
-];
-
-export const fakeLinks: Link[] = [
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Bug Report 1',
- icon: LinkIcon.REPORT_BUG,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Bug Report 2',
- icon: LinkIcon.REPORT_BUG,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Link 1',
- icon: LinkIcon.EXTERNAL,
- },
- {
- url: 'https://www.google.com',
- primary: false,
- tooltip: 'Fake Link 2',
- icon: LinkIcon.EXTERNAL,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Code Link',
- icon: LinkIcon.CODE,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Image Link',
- icon: LinkIcon.IMAGE,
- },
- {
- url: 'https://www.google.com',
- primary: true,
- tooltip: 'Fake Help Link',
- icon: LinkIcon.HELP_PAGE,
- },
-];
-
-export function getPluginState(
- state: ChecksState,
- patchset: ChecksPatchset = ChecksPatchset.LATEST
-) {
- if (patchset === ChecksPatchset.LATEST) {
- state.pluginStateLatest = {...state.pluginStateLatest};
- return state.pluginStateLatest;
- } else {
- state.pluginStateSelected = {...state.pluginStateSelected};
- return state.pluginStateSelected;
+ finalize() {
+ document.removeEventListener('reload', this.reloadListener);
+ document.removeEventListener(
+ 'visibilitychange',
+ this.visibilityChangeListener
+ );
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
+ this.privateState$.complete();
}
-}
-export function updateStateSetLoading(
- pluginName: string,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: true,
- };
- privateState$.next(nextState);
-}
-
-export function updateStateSetError(
- pluginName: string,
- errorMessage: string,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: false,
- firstTimeLoad: false,
- errorMessage,
- loginCallback: undefined,
- runs: [],
- actions: [],
- };
- privateState$.next(nextState);
-}
-
-export function updateStateSetNotLoggedIn(
- pluginName: string,
- loginCallback: () => void,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: false,
- firstTimeLoad: false,
- errorMessage: undefined,
- loginCallback,
- runs: [],
- actions: [],
- };
- privateState$.next(nextState);
-}
-
-export function updateStateSetResults(
- pluginName: string,
- runs: CheckRunApi[],
- actions: Action[] = [],
- links: Link[] = [],
- patchset: ChecksPatchset
-) {
- const attemptMap = createAttemptMap(runs);
- for (const attemptInfo of attemptMap.values()) {
- // Per run only one attempt can be undefined, so the '?? -1' is not really
- // relevant for sorting.
- attemptInfo.attempts.sort((a, b) => (a.attempt ?? -1) - (b.attempt ?? -1));
+ // Must only be used by the checks service or whatever is in control of this
+ // model.
+ updateStateSetProvider(pluginName: string, patchset: ChecksPatchset) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ pluginName,
+ loading: false,
+ firstTimeLoad: true,
+ runs: [],
+ actions: [],
+ links: [],
+ };
+ this.privateState$.next(nextState);
}
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- loading: false,
- firstTimeLoad: false,
- errorMessage: undefined,
- loginCallback: undefined,
- runs: runs.map(run => {
- const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
- const attemptInfo = attemptMap.get(run.checkName);
- assertIsDefined(attemptInfo, 'attemptInfo');
- return {
- ...run,
- pluginName,
- internalRunId: runId,
- isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
- isSingleAttempt: attemptInfo.isSingleAttempt,
- attemptDetails: attemptInfo.attempts,
- results: (run.results ?? []).map((result, i) => {
- return {
- ...result,
- internalResultId: `${runId}-${i}`,
- };
- }),
- };
- }),
- actions: [...actions],
- links: [...links],
- };
- privateState$.next(nextState);
-}
-export function updateStateUpdateResult(
- pluginName: string,
- updatedRun: CheckRunApi,
- updatedResult: CheckResultApi,
- patchset: ChecksPatchset
-) {
- const nextState = {...privateState$.getValue()};
- const pluginState = getPluginState(nextState, patchset);
- let runUpdated = false;
- const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
- if (run.change !== updatedRun.change) return run;
- if (run.patchset !== updatedRun.patchset) return run;
- if (run.attempt !== updatedRun.attempt) return run;
- if (run.checkName !== updatedRun.checkName) return run;
- let resultUpdated = false;
- const results: CheckResult[] = (run.results ?? []).map(result => {
- if (result.externalId && result.externalId === updatedResult.externalId) {
- runUpdated = true;
- resultUpdated = true;
+ getPluginState(
+ state: ChecksState,
+ patchset: ChecksPatchset = ChecksPatchset.LATEST
+ ) {
+ if (patchset === ChecksPatchset.LATEST) {
+ state.pluginStateLatest = {...state.pluginStateLatest};
+ return state.pluginStateLatest;
+ } else {
+ state.pluginStateSelected = {...state.pluginStateSelected};
+ return state.pluginStateSelected;
+ }
+ }
+
+ updateStateSetLoading(pluginName: string, patchset: ChecksPatchset) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: true,
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetError(
+ pluginName: string,
+ errorMessage: string,
+ patchset: ChecksPatchset
+ ) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: false,
+ firstTimeLoad: false,
+ errorMessage,
+ loginCallback: undefined,
+ runs: [],
+ actions: [],
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetNotLoggedIn(
+ pluginName: string,
+ loginCallback: () => void,
+ patchset: ChecksPatchset
+ ) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: false,
+ firstTimeLoad: false,
+ errorMessage: undefined,
+ loginCallback,
+ runs: [],
+ actions: [],
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetResults(
+ pluginName: string,
+ runs: CheckRunApi[],
+ actions: Action[] = [],
+ links: Link[] = [],
+ patchset: ChecksPatchset
+ ) {
+ const attemptMap = createAttemptMap(runs);
+ for (const attemptInfo of attemptMap.values()) {
+ // Per run only one attempt can be undefined, so the '?? -1' is not really
+ // relevant for sorting.
+ attemptInfo.attempts.sort(
+ (a, b) => (a.attempt ?? -1) - (b.attempt ?? -1)
+ );
+ }
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ loading: false,
+ firstTimeLoad: false,
+ errorMessage: undefined,
+ loginCallback: undefined,
+ runs: runs.map(run => {
+ const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
+ const attemptInfo = attemptMap.get(run.checkName);
+ assertIsDefined(attemptInfo, 'attemptInfo');
return {
- ...updatedResult,
- internalResultId: result.internalResultId,
+ ...run,
+ pluginName,
+ internalRunId: runId,
+ isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
+ isSingleAttempt: attemptInfo.isSingleAttempt,
+ attemptDetails: attemptInfo.attempts,
+ results: (run.results ?? []).map((result, i) => {
+ return {
+ ...result,
+ internalResultId: `${runId}-${i}`,
+ };
+ }),
};
- }
- return result;
- });
- return resultUpdated ? {...run, results} : run;
- });
- if (!runUpdated) return;
- pluginState[pluginName] = {
- ...pluginState[pluginName],
- runs,
- };
- privateState$.next(nextState);
-}
+ }),
+ actions: [...actions],
+ links: [...links],
+ };
+ this.privateState$.next(nextState);
+ }
-export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
- const nextState = {...privateState$.getValue()};
- nextState.patchsetNumberSelected = patchsetNumber;
- privateState$.next(nextState);
+ updateStateUpdateResult(
+ pluginName: string,
+ updatedRun: CheckRunApi,
+ updatedResult: CheckResultApi,
+ patchset: ChecksPatchset
+ ) {
+ const nextState = {...this.privateState$.getValue()};
+ const pluginState = this.getPluginState(nextState, patchset);
+ let runUpdated = false;
+ const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
+ if (run.change !== updatedRun.change) return run;
+ if (run.patchset !== updatedRun.patchset) return run;
+ if (run.attempt !== updatedRun.attempt) return run;
+ if (run.checkName !== updatedRun.checkName) return run;
+ let resultUpdated = false;
+ const results: CheckResult[] = (run.results ?? []).map(result => {
+ if (
+ result.externalId &&
+ result.externalId === updatedResult.externalId
+ ) {
+ runUpdated = true;
+ resultUpdated = true;
+ return {
+ ...updatedResult,
+ internalResultId: result.internalResultId,
+ };
+ }
+ return result;
+ });
+ return resultUpdated ? {...run, results} : run;
+ });
+ if (!runUpdated) return;
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
+ runs,
+ };
+ this.privateState$.next(nextState);
+ }
+
+ updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+ const nextState = {...this.privateState$.getValue()};
+ nextState.patchsetNumberSelected = patchsetNumber;
+ this.privateState$.next(nextState);
+ }
+
+ setPatchset(num?: PatchSetNumber) {
+ this.updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
+ }
+
+ reload(pluginName: string) {
+ this.reloadSubjects[pluginName].next();
+ }
+
+ reloadAll() {
+ for (const key of Object.keys(this.providers)) {
+ this.reload(key);
+ }
+ }
+
+ reloadForCheck(checkName?: string) {
+ if (!checkName) return;
+ const plugin = this.checkToPluginMap.get(checkName);
+ if (plugin) this.reload(plugin);
+ }
+
+ updateResult(pluginName: string, run: CheckRunApi, result: CheckResultApi) {
+ this.updateStateUpdateResult(
+ pluginName,
+ run,
+ result,
+ ChecksPatchset.LATEST
+ );
+ this.updateStateUpdateResult(
+ pluginName,
+ run,
+ result,
+ ChecksPatchset.SELECTED
+ );
+ }
+
+ triggerAction(action?: Action, run?: CheckRun) {
+ if (!action?.callback) return;
+ if (!this.changeNum) return;
+ const patchSet = run?.patchset ?? this.latestPatchNum;
+ if (!patchSet) return;
+ const promise = action.callback(
+ this.changeNum,
+ patchSet,
+ run?.attempt,
+ run?.externalId,
+ run?.checkName,
+ action.name
+ );
+ // If plugins return undefined or not a promise, then show no toast.
+ if (!promise?.then) return;
+
+ fireAlert(document, `Triggering action '${action.name}' ...`);
+ from(promise)
+ // If the action takes longer than 5 seconds, then most likely the
+ // user is either not interested or the result not relevant anymore.
+ .pipe(takeUntil(timer(5000)))
+ .subscribe(result => {
+ if (result.errorMessage || result.message) {
+ fireAlert(document, `${result.message ?? result.errorMessage}`);
+ } else {
+ fireEvent(document, 'hide-alert');
+ }
+ if (result.shouldReload) {
+ this.reloadForCheck(run?.checkName);
+ }
+ });
+ }
+
+ register(
+ pluginName: string,
+ provider: ChecksProvider,
+ config: ChecksApiConfig
+ ) {
+ if (this.providers[pluginName]) {
+ console.warn(
+ `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
+ );
+ return;
+ }
+ this.providers[pluginName] = provider;
+ this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+ this.updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+ this.updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
+ this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+ this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+ }
+
+ initFetchingOfData(
+ pluginName: string,
+ config: ChecksApiConfig,
+ patchset: ChecksPatchset
+ ) {
+ const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+ // Various events should trigger fetching checks from the provider:
+ // 1. Change number and patchset number changes.
+ // 2. Specific reload requests.
+ // 3. Regular polling starting with an initial fetch right now.
+ // 4. A hidden Gerrit tab becoming visible.
+ this.subscriptions.push(
+ combineLatest([
+ this.changeModel.changeNum$,
+ patchset === ChecksPatchset.LATEST
+ ? this.changeModel.latestPatchNum$
+ : this.checksSelectedPatchsetNumber$,
+ this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
+ timer(0, pollIntervalMs),
+ this.documentVisibilityChange$,
+ ])
+ .pipe(
+ takeWhile(_ => !!this.providers[pluginName]),
+ filter(_ => document.visibilityState !== 'hidden'),
+ withLatestFrom(this.changeModel.change$),
+ switchMap(
+ ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
+ if (!change || !changeNum || !patchNum) return of(this.empty());
+ if (typeof patchNum !== 'number') return of(this.empty());
+ assertIsDefined(change.revisions, 'change.revisions');
+ const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+ // Sometimes patchNum is updated earlier than change, so change
+ // revisions don't have patchNum yet
+ if (!patchsetSha) return of(this.empty());
+ const data: ChangeData = {
+ changeNumber: changeNum,
+ patchsetNumber: patchNum,
+ patchsetSha,
+ repo: change.project,
+ commitMessage: getCurrentRevision(change)?.commit?.message,
+ changeInfo: change as ChangeInfo,
+ };
+ return this.fetchResults(pluginName, data, patchset);
+ }
+ ),
+ catchError(e => {
+ // This should not happen and is really severe, because it means that
+ // the Observable has terminated and we won't recover from that. No
+ // further attempts to fetch results for this plugin will be made.
+ this.reporting.error(e, `checks-model crash for ${pluginName}`);
+ return of(this.createErrorResponse(pluginName, e));
+ })
+ )
+ .subscribe(response => {
+ switch (response.responseCode) {
+ case ResponseCode.ERROR: {
+ const message = response.errorMessage ?? '-';
+ this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
+ plugin: pluginName,
+ message,
+ });
+ this.updateStateSetError(pluginName, message, patchset);
+ break;
+ }
+ case ResponseCode.NOT_LOGGED_IN: {
+ assertIsDefined(response.loginCallback, 'loginCallback');
+ this.reporting.reportExecution(
+ Execution.CHECKS_API_NOT_LOGGED_IN,
+ {
+ plugin: pluginName,
+ }
+ );
+ this.updateStateSetNotLoggedIn(
+ pluginName,
+ response.loginCallback,
+ patchset
+ );
+ break;
+ }
+ case ResponseCode.OK: {
+ this.updateStateSetResults(
+ pluginName,
+ response.runs ?? [],
+ response.actions ?? [],
+ response.links ?? [],
+ patchset
+ );
+ break;
+ }
+ }
+ })
+ );
+ }
+
+ private empty(): FetchResponse {
+ return {
+ responseCode: ResponseCode.OK,
+ runs: [],
+ };
+ }
+
+ private createErrorResponse(
+ pluginName: string,
+ message: object
+ ): FetchResponse {
+ return {
+ responseCode: ResponseCode.ERROR,
+ errorMessage:
+ `Error message from plugin '${pluginName}':` +
+ ` ${JSON.stringify(message)}`,
+ };
+ }
+
+ private fetchResults(
+ pluginName: string,
+ data: ChangeData,
+ patchset: ChecksPatchset
+ ): Observable<FetchResponse> {
+ this.updateStateSetLoading(pluginName, patchset);
+ const timer = this.reporting.getTimer('ChecksPluginFetch');
+ const fetchPromise = this.providers[pluginName]
+ .fetch(data)
+ .then(response => {
+ timer.end({pluginName});
+ return response;
+ });
+ return from(fetchPromise).pipe(
+ catchError(e => of(this.createErrorResponse(pluginName, e)))
+ );
+ }
}
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
index 0be0451..0d46289 100644
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -16,15 +16,9 @@
*/
import '../../test/common-test-setup-karma';
import './checks-model';
-import {
- _testOnly_getState,
- ChecksPatchset,
- updateStateSetLoading,
- updateStateSetProvider,
- updateStateSetResults,
- updateStateUpdateResult,
-} from './checks-model';
+import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
import {Category, CheckRun, RunStatus} from '../../api/checks';
+import {getAppContext} from '../app-context';
const PLUGIN_NAME = 'test-plugin';
@@ -45,14 +39,26 @@
},
];
-function current() {
- return _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
-}
-
suite('checks-model tests', () => {
- test('updateStateSetProvider', () => {
- updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.deepEqual(current(), {
+ let model: ChecksModel;
+
+ let current: ChecksProviderState;
+
+ setup(() => {
+ model = new ChecksModel(
+ getAppContext().changeModel,
+ getAppContext().reportingService
+ );
+ model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
+ });
+
+ teardown(() => {
+ model.finalize();
+ });
+
+ test('model.updateStateSetProvider', () => {
+ model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.deepEqual(current, {
pluginName: PLUGIN_NAME,
loading: false,
firstTimeLoad: true,
@@ -63,45 +69,69 @@
});
test('loading and first time load', () => {
- updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.isFalse(current().loading);
- assert.isTrue(current().firstTimeLoad);
- updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.isTrue(current().loading);
- assert.isTrue(current().firstTimeLoad);
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
- assert.isFalse(current().loading);
- assert.isFalse(current().firstTimeLoad);
- updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
- assert.isTrue(current().loading);
- assert.isFalse(current().firstTimeLoad);
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
- assert.isFalse(current().loading);
- assert.isFalse(current().firstTimeLoad);
+ model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.isFalse(current.loading);
+ assert.isTrue(current.firstTimeLoad);
+ model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.isTrue(current.loading);
+ assert.isTrue(current.firstTimeLoad);
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ assert.isFalse(current.loading);
+ assert.isFalse(current.firstTimeLoad);
+ model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+ assert.isTrue(current.loading);
+ assert.isFalse(current.firstTimeLoad);
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ assert.isFalse(current.loading);
+ assert.isFalse(current.firstTimeLoad);
});
- test('updateStateSetResults', () => {
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
- assert.lengthOf(current().runs, 1);
- assert.lengthOf(current().runs[0].results!, 1);
+ test('model.updateStateSetResults', () => {
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
+ assert.lengthOf(current.runs, 1);
+ assert.lengthOf(current.runs[0].results!, 1);
});
- test('updateStateUpdateResult', () => {
- updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+ test('model.updateStateUpdateResult', () => {
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ RUNS,
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
assert.equal(
- current().runs[0].results![0].summary,
+ current.runs[0].results![0].summary,
RUNS[0]!.results![0].summary
);
const result = RUNS[0].results![0];
const updatedResult = {...result, summary: 'new'};
- updateStateUpdateResult(
+ model.updateStateUpdateResult(
PLUGIN_NAME,
RUNS[0],
updatedResult,
ChecksPatchset.LATEST
);
- assert.lengthOf(current().runs, 1);
- assert.lengthOf(current().runs[0].results!, 1);
- assert.equal(current().runs[0].results![0].summary, 'new');
+ assert.lengthOf(current.runs, 1);
+ assert.lengthOf(current.runs[0].results!, 1);
+ assert.equal(current.runs[0].results![0].summary, 'new');
});
});
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
deleted file mode 100644
index 111036c..0000000
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {
- BehaviorSubject,
- combineLatest,
- from,
- Observable,
- of,
- Subject,
- Subscription,
- timer,
-} from 'rxjs';
-import {
- catchError,
- filter,
- switchMap,
- takeUntil,
- takeWhile,
- throttleTime,
- withLatestFrom,
-} from 'rxjs/operators';
-import {
- Action,
- ChangeData,
- CheckResult,
- CheckRun,
- ChecksApiConfig,
- ChecksProvider,
- FetchResponse,
- ResponseCode,
-} from '../../api/checks';
-import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
-import {
- ChecksPatchset,
- checksSelectedPatchsetNumber$,
- checkToPluginMap$,
- updateStateSetError,
- updateStateSetLoading,
- updateStateSetNotLoggedIn,
- updateStateSetPatchset,
- updateStateSetProvider,
- updateStateSetResults,
- updateStateUpdateResult,
-} from './checks-model';
-import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
-import {Finalizable} from '../registry';
-import {getCurrentRevision} from '../../utils/change-util';
-import {getShaByPatchNum} from '../../utils/patch-set-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {ReportingService} from '../gr-reporting/gr-reporting';
-import {routerPatchNum$} from '../router/router-model';
-import {Execution} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
-
-export class ChecksService implements Finalizable {
- private readonly providers: {[name: string]: ChecksProvider} = {};
-
- private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
-
- private checkToPluginMap = new Map<string, string>();
-
- private changeNum?: NumericChangeId;
-
- private latestPatchNum?: PatchSetNumber;
-
- private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
-
- private readonly reloadListener: () => void;
-
- private readonly subscriptions: Subscription[] = [];
-
- private readonly visibilityChangeListener: () => void;
-
- constructor(readonly reporting: ReportingService) {
- this.subscriptions.push(changeNum$.subscribe(x => (this.changeNum = x)));
- this.subscriptions.push(
- checkToPluginMap$.subscribe(map => {
- this.checkToPluginMap = map;
- })
- );
- this.subscriptions.push(
- combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
- ([routerPs, latestPs]) => {
- this.latestPatchNum = latestPs;
- if (latestPs === undefined) {
- this.setPatchset(undefined);
- } else if (typeof routerPs === 'number') {
- this.setPatchset(routerPs);
- } else {
- this.setPatchset(latestPs);
- }
- }
- )
- );
- this.visibilityChangeListener = () => {
- this.documentVisibilityChange$.next(undefined);
- };
- document.addEventListener(
- 'visibilitychange',
- this.visibilityChangeListener
- );
- this.reloadListener = () => this.reloadAll();
- document.addEventListener('reload', this.reloadListener);
- }
-
- finalize() {
- document.removeEventListener('reload', this.reloadListener);
- document.removeEventListener(
- 'visibilitychange',
- this.visibilityChangeListener
- );
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-
- setPatchset(num?: PatchSetNumber) {
- updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
- }
-
- reload(pluginName: string) {
- this.reloadSubjects[pluginName].next();
- }
-
- reloadAll() {
- Object.keys(this.providers).forEach(key => this.reload(key));
- }
-
- reloadForCheck(checkName?: string) {
- if (!checkName) return;
- const plugin = this.checkToPluginMap.get(checkName);
- if (plugin) this.reload(plugin);
- }
-
- updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
- updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
- updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
- }
-
- triggerAction(action?: Action, run?: CheckRun) {
- if (!action?.callback) return;
- if (!this.changeNum) return;
- const patchSet = run?.patchset ?? this.latestPatchNum;
- if (!patchSet) return;
- const promise = action.callback(
- this.changeNum,
- patchSet,
- run?.attempt,
- run?.externalId,
- run?.checkName,
- action.name
- );
- // If plugins return undefined or not a promise, then show no toast.
- if (!promise?.then) return;
-
- fireAlert(document, `Triggering action '${action.name}' ...`);
- from(promise)
- // If the action takes longer than 5 seconds, then most likely the
- // user is either not interested or the result not relevant anymore.
- .pipe(takeUntil(timer(5000)))
- .subscribe(result => {
- if (result.errorMessage || result.message) {
- fireAlert(document, `${result.message ?? result.errorMessage}`);
- } else {
- fireEvent(document, 'hide-alert');
- }
- if (result.shouldReload) {
- this.reloadForCheck(run?.checkName);
- }
- });
- }
-
- register(
- pluginName: string,
- provider: ChecksProvider,
- config: ChecksApiConfig
- ) {
- if (this.providers[pluginName]) {
- console.warn(
- `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
- );
- return;
- }
- this.providers[pluginName] = provider;
- this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
- updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
- updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
- this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
- this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
- }
-
- initFetchingOfData(
- pluginName: string,
- config: ChecksApiConfig,
- patchset: ChecksPatchset
- ) {
- const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
- // Various events should trigger fetching checks from the provider:
- // 1. Change number and patchset number changes.
- // 2. Specific reload requests.
- // 3. Regular polling starting with an initial fetch right now.
- // 4. A hidden Gerrit tab becoming visible.
- this.subscriptions.push(
- combineLatest([
- changeNum$,
- patchset === ChecksPatchset.LATEST
- ? latestPatchNum$
- : checksSelectedPatchsetNumber$,
- this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
- timer(0, pollIntervalMs),
- this.documentVisibilityChange$,
- ])
- .pipe(
- takeWhile(_ => !!this.providers[pluginName]),
- filter(_ => document.visibilityState !== 'hidden'),
- withLatestFrom(change$),
- switchMap(
- ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
- if (!change || !changeNum || !patchNum) return of(this.empty());
- if (typeof patchNum !== 'number') return of(this.empty());
- assertIsDefined(change.revisions, 'change.revisions');
- const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
- // Sometimes patchNum is updated earlier than change, so change
- // revisions don't have patchNum yet
- if (!patchsetSha) return of(this.empty());
- const data: ChangeData = {
- changeNumber: changeNum,
- patchsetNumber: patchNum,
- patchsetSha,
- repo: change.project,
- commitMessage: getCurrentRevision(change)?.commit?.message,
- changeInfo: change as ChangeInfo,
- };
- return this.fetchResults(pluginName, data, patchset);
- }
- ),
- catchError(e => {
- // This should not happen and is really severe, because it means that
- // the Observable has terminated and we won't recover from that. No
- // further attempts to fetch results for this plugin will be made.
- this.reporting.error(e, `checks-service crash for ${pluginName}`);
- return of(this.createErrorResponse(pluginName, e));
- })
- )
- .subscribe(response => {
- switch (response.responseCode) {
- case ResponseCode.ERROR: {
- const message = response.errorMessage ?? '-';
- this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
- plugin: pluginName,
- message,
- });
- updateStateSetError(pluginName, message, patchset);
- break;
- }
- case ResponseCode.NOT_LOGGED_IN: {
- assertIsDefined(response.loginCallback, 'loginCallback');
- this.reporting.reportExecution(
- Execution.CHECKS_API_NOT_LOGGED_IN,
- {
- plugin: pluginName,
- }
- );
- updateStateSetNotLoggedIn(
- pluginName,
- response.loginCallback,
- patchset
- );
- break;
- }
- case ResponseCode.OK: {
- updateStateSetResults(
- pluginName,
- response.runs ?? [],
- response.actions ?? [],
- response.links ?? [],
- patchset
- );
- break;
- }
- }
- })
- );
- }
-
- private empty(): FetchResponse {
- return {
- responseCode: ResponseCode.OK,
- runs: [],
- };
- }
-
- private createErrorResponse(
- pluginName: string,
- message: object
- ): FetchResponse {
- return {
- responseCode: ResponseCode.ERROR,
- errorMessage:
- `Error message from plugin '${pluginName}':` +
- ` ${JSON.stringify(message)}`,
- };
- }
-
- private fetchResults(
- pluginName: string,
- data: ChangeData,
- patchset: ChecksPatchset
- ): Observable<FetchResponse> {
- updateStateSetLoading(pluginName, patchset);
- const timer = this.reporting.getTimer('ChecksPluginFetch');
- const fetchPromise = this.providers[pluginName]
- .fetch(data)
- .then(response => {
- timer.end({pluginName});
- return response;
- });
- return from(fetchPromise).pipe(
- catchError(e => of(this.createErrorResponse(pluginName, e)))
- );
- }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index ad7865b39..d46e08a 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -15,23 +15,53 @@
* limitations under the License.
*/
-import {BehaviorSubject, Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
+import {BehaviorSubject} from 'rxjs';
import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
import {
+ CommentBasics,
CommentInfo,
+ NumericChangeId,
+ PatchSetNum,
+ RevisionId,
+ UrlEncodedCommentId,
PathToCommentsInfoMap,
RobotCommentInfo,
} from '../../types/common';
-import {addPath, DraftInfo} from '../../utils/comment-util';
+import {
+ addPath,
+ DraftInfo,
+ isDraft,
+ isUnsaved,
+ reportingDetails,
+ UnsavedInfo,
+} from '../../utils/comment-util';
+import {deepEqual} from '../../utils/deep-util';
+import {select} from '../../utils/observable-util';
+import {routerChangeNum$} from '../router/router-model';
+import {Finalizable} from '../registry';
+import {combineLatest, Subscription} from 'rxjs';
+import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {Interaction} from '../../constants/reporting';
+import {assertIsDefined} from '../../utils/common-util';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {pluralize} from '../../utils/string-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
-interface CommentState {
+export interface CommentState {
/** undefined means 'still loading' */
comments?: PathToCommentsInfoMap;
/** undefined means 'still loading' */
robotComments?: {[path: string]: RobotCommentInfo[]};
+ // All drafts are DraftInfo objects and have __draft = true set.
+ // Drafts have an id and are known to the backend. Unsaved drafts
+ // (see UnsavedInfo) do NOT belong in the application model.
/** undefined means 'still loading' */
drafts?: {[path: string]: DraftInfo[]};
+ // Ported comments only affect `CommentThread` properties, not individual
+ // comments.
/** undefined means 'still loading' */
portedComments?: PathToCommentsInfoMap;
/** undefined means 'still loading' */
@@ -53,60 +83,179 @@
discardedDrafts: [],
};
-const privateState$ = new BehaviorSubject(initialState);
+const TOAST_DEBOUNCE_INTERVAL = 200;
-export function _testOnly_resetState() {
- // We cannot assign a new subject to privateState$, because all the selectors
- // have already subscribed to the original subject. So we have to emit the
- // initial state on the existing subject.
- privateState$.next({...initialState});
+function getSavingMessage(numPending: number, requestFailed?: boolean) {
+ if (requestFailed) {
+ return 'Unable to save draft';
+ }
+ if (numPending === 0) {
+ return 'All changes saved';
+ }
+ return `Saving ${pluralize(numPending, 'draft')}...`;
}
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const commentState$: Observable<CommentState> = privateState$;
-
-export function _testOnly_getState() {
- return privateState$.getValue();
+// Private but used in tests.
+export function setComments(
+ state: CommentState,
+ comments?: {
+ [path: string]: CommentInfo[];
+ }
+): CommentState {
+ const nextState = {...state};
+ if (deepEqual(comments, nextState.comments)) return state;
+ nextState.comments = addPath(comments) || {};
+ return nextState;
}
-export function _testOnly_setState(state: CommentState) {
- privateState$.next(state);
+// Private but used in tests.
+export function setRobotComments(
+ state: CommentState,
+ robotComments?: {
+ [path: string]: RobotCommentInfo[];
+ }
+): CommentState {
+ if (deepEqual(robotComments, state.robotComments)) return state;
+ const nextState = {...state};
+ nextState.robotComments = addPath(robotComments) || {};
+ return nextState;
}
-export const commentsLoading$ = commentState$.pipe(
- map(
+// Private but used in tests.
+export function setDrafts(
+ state: CommentState,
+ drafts?: {[path: string]: DraftInfo[]}
+): CommentState {
+ if (deepEqual(drafts, state.drafts)) return state;
+ const nextState = {...state};
+ nextState.drafts = addPath(drafts);
+ return nextState;
+}
+
+// Private but used in tests.
+export function setPortedComments(
+ state: CommentState,
+ portedComments?: PathToCommentsInfoMap
+): CommentState {
+ if (deepEqual(portedComments, state.portedComments)) return state;
+ const nextState = {...state};
+ nextState.portedComments = portedComments || {};
+ return nextState;
+}
+
+// Private but used in tests.
+export function setPortedDrafts(
+ state: CommentState,
+ portedDrafts?: PathToCommentsInfoMap
+): CommentState {
+ if (deepEqual(portedDrafts, state.portedDrafts)) return state;
+ const nextState = {...state};
+ nextState.portedDrafts = portedDrafts || {};
+ return nextState;
+}
+
+// Private but used in tests.
+export function setDiscardedDraft(
+ state: CommentState,
+ draft: DraftInfo
+): CommentState {
+ const nextState = {...state};
+ nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
+ return nextState;
+}
+
+// Private but used in tests.
+export function deleteDiscardedDraft(
+ state: CommentState,
+ draftID?: string
+): CommentState {
+ const nextState = {...state};
+ const drafts = [...nextState.discardedDrafts];
+ const index = drafts.findIndex(d => d.id === draftID);
+ if (index === -1) {
+ throw new Error('discarded draft not found');
+ }
+ drafts.splice(index, 1);
+ nextState.discardedDrafts = drafts;
+ return nextState;
+}
+
+/** Adds or updates a draft. */
+export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
+ const nextState = {...state};
+ if (!draft.path) throw new Error('draft path undefined');
+ if (!isDraft(draft)) throw new Error('draft is not a draft');
+ if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+
+ nextState.drafts = {...nextState.drafts};
+ const drafts = nextState.drafts;
+ if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+ else drafts[draft.path] = [...drafts[draft.path]];
+ const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
+ if (index !== -1) {
+ drafts[draft.path][index] = draft;
+ } else {
+ drafts[draft.path].push(draft);
+ }
+ return nextState;
+}
+
+export function deleteDraft(
+ state: CommentState,
+ draft: DraftInfo
+): CommentState {
+ const nextState = {...state};
+ if (!draft.path) throw new Error('draft path undefined');
+ if (!isDraft(draft)) throw new Error('draft is not a draft');
+ if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+ nextState.drafts = {...nextState.drafts};
+ const drafts = nextState.drafts;
+ const index = (drafts[draft.path] || []).findIndex(
+ d => d.id && d.id === draft.id
+ );
+ if (index === -1) return state;
+ const discardedDraft = drafts[draft.path][index];
+ drafts[draft.path] = [...drafts[draft.path]];
+ drafts[draft.path].splice(index, 1);
+ return setDiscardedDraft(nextState, discardedDraft);
+}
+
+export class CommentsModel implements Finalizable {
+ private readonly privateState$: BehaviorSubject<CommentState> =
+ new BehaviorSubject(initialState);
+
+ public readonly commentsLoading$ = select(
+ this.privateState$,
commentState =>
commentState.comments === undefined ||
commentState.robotComments === undefined ||
commentState.drafts === undefined
- ),
- distinctUntilChanged()
-);
+ );
-export const comments$ = commentState$.pipe(
- map(commentState => commentState.comments),
- distinctUntilChanged()
-);
+ public readonly comments$ = select(
+ this.privateState$,
+ commentState => commentState.comments
+ );
-export const drafts$ = commentState$.pipe(
- map(commentState => commentState.drafts),
- distinctUntilChanged()
-);
+ public readonly drafts$ = select(
+ this.privateState$,
+ commentState => commentState.drafts
+ );
-export const portedComments$ = commentState$.pipe(
- map(commentState => commentState.portedComments),
- distinctUntilChanged()
-);
+ public readonly portedComments$ = select(
+ this.privateState$,
+ commentState => commentState.portedComments
+ );
-export const discardedDrafts$ = commentState$.pipe(
- map(commentState => commentState.discardedDrafts),
- distinctUntilChanged()
-);
+ public readonly discardedDrafts$ = select(
+ this.privateState$,
+ commentState => commentState.discardedDrafts
+ );
-// Emits a new value even if only a single draft is changed. Components should
-// aim to subsribe to something more specific.
-export const changeComments$ = commentState$.pipe(
- map(
+ // Emits a new value even if only a single draft is changed. Components should
+ // aim to subsribe to something more specific.
+ public readonly changeComments$ = select(
+ this.privateState$,
commentState =>
new ChangeComments(
commentState.comments,
@@ -115,128 +264,282 @@
commentState.portedComments,
commentState.portedDrafts
)
- )
-);
+ );
-export const threads$ = changeComments$.pipe(
- map(changeComments => changeComments.getAllThreadsForChange())
-);
+ public readonly threads$ = select(this.changeComments$, changeComments =>
+ changeComments.getAllThreadsForChange()
+ );
-function publishState(state: CommentState) {
- privateState$.next(state);
-}
-
-/** Called when the change number changes. Wipes out all data from the state. */
-export function updateStateReset() {
- publishState({...initialState});
-}
-
-export function updateStateComments(comments?: {
- [path: string]: CommentInfo[];
-}) {
- const nextState = {...privateState$.getValue()};
- nextState.comments = addPath(comments) || {};
- publishState(nextState);
-}
-
-export function updateStateRobotComments(robotComments?: {
- [path: string]: RobotCommentInfo[];
-}) {
- const nextState = {...privateState$.getValue()};
- nextState.robotComments = addPath(robotComments) || {};
- publishState(nextState);
-}
-
-export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
- const nextState = {...privateState$.getValue()};
- nextState.drafts = addPath(drafts) || {};
- publishState(nextState);
-}
-
-export function updateStatePortedComments(
- portedComments?: PathToCommentsInfoMap
-) {
- const nextState = {...privateState$.getValue()};
- nextState.portedComments = portedComments || {};
- publishState(nextState);
-}
-
-export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
- const nextState = {...privateState$.getValue()};
- nextState.portedDrafts = portedDrafts || {};
- publishState(nextState);
-}
-
-export function updateStateAddDiscardedDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
- publishState(nextState);
-}
-
-export function updateStateUndoDiscardedDraft(draftID?: string) {
- const nextState = {...privateState$.getValue()};
- const drafts = [...nextState.discardedDrafts];
- const index = drafts.findIndex(d => d.id === draftID);
- if (index === -1) {
- throw new Error('discarded draft not found');
+ public thread$(id: UrlEncodedCommentId) {
+ return select(this.threads$, threads => threads.find(t => t.rootId === id));
}
- drafts.splice(index, 1);
- nextState.discardedDrafts = drafts;
- publishState(nextState);
-}
-export function updateStateAddDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- if (!draft.path) throw new Error('draft path undefined');
- nextState.drafts = {...nextState.drafts};
- const drafts = nextState.drafts;
- if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
- else drafts[draft.path] = [...drafts[draft.path]];
- const index = drafts[draft.path].findIndex(
- d =>
- (d.__draftID && d.__draftID === draft.__draftID) ||
- (d.id && d.id === draft.id)
- );
- if (index !== -1) {
- drafts[draft.path][index] = draft;
- } else {
- drafts[draft.path].push(draft);
+ private numPendingDraftRequests = 0;
+
+ private changeNum?: NumericChangeId;
+
+ private patchNum?: PatchSetNum;
+
+ private readonly reloadListener: () => void;
+
+ private readonly subscriptions: Subscription[] = [];
+
+ private drafts: {[path: string]: DraftInfo[]} = {};
+
+ private draftToastTask?: DelayedTask;
+
+ private discardedDrafts: DraftInfo[] = [];
+
+ constructor(
+ readonly changeModel: ChangeModel,
+ readonly restApiService: RestApiService,
+ readonly reporting: ReportingService
+ ) {
+ this.subscriptions.push(
+ this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
+ );
+ this.subscriptions.push(
+ this.drafts$.subscribe(x => (this.drafts = x ?? {}))
+ );
+ this.subscriptions.push(
+ this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
+ );
+ this.subscriptions.push(
+ routerChangeNum$.subscribe(changeNum => {
+ this.changeNum = changeNum;
+ this.setState({...initialState});
+ this.reloadAllComments();
+ })
+ );
+ this.subscriptions.push(
+ combineLatest([
+ this.changeModel.changeNum$,
+ this.changeModel.currentPatchNum$,
+ ]).subscribe(([changeNum, patchNum]) => {
+ this.changeNum = changeNum;
+ this.patchNum = patchNum;
+ this.reloadAllPortedComments();
+ })
+ );
+ this.reloadListener = () => {
+ this.reloadAllComments();
+ this.reloadAllPortedComments();
+ };
+ document.addEventListener('reload', this.reloadListener);
}
- publishState(nextState);
-}
-export function updateStateUpdateDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- if (!draft.path) throw new Error('draft path undefined');
- nextState.drafts = {...nextState.drafts};
- const drafts = nextState.drafts;
- if (!drafts[draft.path])
- throw new Error('draft: trying to edit non-existent draft');
- drafts[draft.path] = [...drafts[draft.path]];
- const index = drafts[draft.path].findIndex(
- d =>
- (d.__draftID && d.__draftID === draft.__draftID) ||
- (d.id && d.id === draft.id)
- );
- if (index === -1) return;
- drafts[draft.path][index] = draft;
- publishState(nextState);
-}
+ finalize() {
+ document.removeEventListener('reload', this.reloadListener!);
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions.splice(0, this.subscriptions.length);
+ }
-export function updateStateDeleteDraft(draft: DraftInfo) {
- const nextState = {...privateState$.getValue()};
- if (!draft.path) throw new Error('draft path undefined');
- nextState.drafts = {...nextState.drafts};
- const drafts = nextState.drafts;
- const index = (drafts[draft.path] || []).findIndex(
- d =>
- (d.__draftID && d.__draftID === draft.__draftID) ||
- (d.id && d.id === draft.id)
- );
- if (index === -1) return;
- const discardedDraft = drafts[draft.path][index];
- drafts[draft.path] = [...drafts[draft.path]];
- drafts[draft.path].splice(index, 1);
- publishState(nextState);
- updateStateAddDiscardedDraft(discardedDraft);
+ // Note that this does *not* reload ported comments.
+ async reloadAllComments() {
+ if (!this.changeNum) return;
+ await Promise.all([
+ this.reloadComments(this.changeNum),
+ this.reloadRobotComments(this.changeNum),
+ this.reloadDrafts(this.changeNum),
+ ]);
+ }
+
+ async reloadAllPortedComments() {
+ if (!this.changeNum) return;
+ if (!this.patchNum) return;
+ await Promise.all([
+ this.reloadPortedComments(this.changeNum, this.patchNum),
+ this.reloadPortedDrafts(this.changeNum, this.patchNum),
+ ]);
+ }
+
+ // visible for testing
+ updateState(reducer: (state: CommentState) => CommentState) {
+ const current = this.privateState$.getValue();
+ this.setState(reducer({...current}));
+ }
+
+ // visible for testing
+ setState(state: CommentState) {
+ this.privateState$.next(state);
+ }
+
+ async reloadComments(changeNum: NumericChangeId): Promise<void> {
+ const comments = await this.restApiService.getDiffComments(changeNum);
+ this.updateState(s => setComments(s, comments));
+ }
+
+ async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
+ const robotComments = await this.restApiService.getDiffRobotComments(
+ changeNum
+ );
+ this.updateState(s => setRobotComments(s, robotComments));
+ }
+
+ async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
+ const drafts = await this.restApiService.getDiffDrafts(changeNum);
+ this.updateState(s => setDrafts(s, drafts));
+ }
+
+ async reloadPortedComments(
+ changeNum: NumericChangeId,
+ patchNum = CURRENT as RevisionId
+ ): Promise<void> {
+ const portedComments = await this.restApiService.getPortedComments(
+ changeNum,
+ patchNum
+ );
+ this.updateState(s => setPortedComments(s, portedComments));
+ }
+
+ async reloadPortedDrafts(
+ changeNum: NumericChangeId,
+ patchNum = CURRENT as RevisionId
+ ): Promise<void> {
+ const portedDrafts = await this.restApiService.getPortedDrafts(
+ changeNum,
+ patchNum
+ );
+ this.updateState(s => setPortedDrafts(s, portedDrafts));
+ }
+
+ async restoreDraft(id: UrlEncodedCommentId) {
+ const found = this.discardedDrafts?.find(d => d.id === id);
+ if (!found) throw new Error('discarded draft not found');
+ const newDraft = {
+ ...found,
+ id: undefined,
+ updated: undefined,
+ __draft: undefined,
+ __unsaved: true,
+ };
+ await this.saveDraft(newDraft);
+ this.updateState(s => deleteDiscardedDraft(s, id));
+ }
+
+ /**
+ * Saves a new or updates an existing draft.
+ * The model will only be updated when a successful response comes back.
+ */
+ async saveDraft(draft: DraftInfo | UnsavedInfo, showToast = true) {
+ assertIsDefined(this.changeNum, 'change number');
+ assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+ if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+
+ // Saving the change number as to make sure that the response is still
+ // relevant when it comes back. The user maybe have navigated away.
+ const changeNum = this.changeNum;
+ this.report(Interaction.SAVE_COMMENT, draft);
+ if (showToast) this.showStartRequest();
+ const result = await this.restApiService.saveDiffDraft(
+ changeNum,
+ draft.patch_set,
+ draft
+ );
+ if (changeNum !== this.changeNum) throw new Error('change changed');
+ if (!result.ok) {
+ if (showToast) this.handleFailedDraftRequest();
+ throw new Error(
+ `Failed to save draft comment: ${JSON.stringify(result)}`
+ );
+ }
+ const obj = await this.restApiService.getResponseObject(result);
+ const savedComment = obj as unknown as CommentInfo;
+ const updatedDraft = {
+ ...draft,
+ id: savedComment.id,
+ updated: savedComment.updated,
+ __draft: true,
+ __unsaved: undefined,
+ };
+ if (showToast) this.showEndRequest();
+ this.updateState(s => setDraft(s, updatedDraft));
+ this.report(Interaction.COMMENT_SAVED, updatedDraft);
+ }
+
+ async discardDraft(draftId: UrlEncodedCommentId) {
+ const draft = this.lookupDraft(draftId);
+ assertIsDefined(this.changeNum, 'change number');
+ assertIsDefined(draft, `draft not found by id ${draftId}`);
+ assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+
+ if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
+ // Saving the change number as to make sure that the response is still
+ // relevant when it comes back. The user maybe have navigated away.
+ const changeNum = this.changeNum;
+ this.report(Interaction.DISCARD_COMMENT, draft);
+ this.showStartRequest();
+ const result = await this.restApiService.deleteDiffDraft(
+ changeNum,
+ draft.patch_set,
+ {id: draft.id}
+ );
+ if (changeNum !== this.changeNum) throw new Error('change changed');
+ if (!result.ok) {
+ this.handleFailedDraftRequest();
+ throw new Error(
+ `Failed to discard draft comment: ${JSON.stringify(result)}`
+ );
+ }
+ this.showEndRequest();
+ this.updateState(s => deleteDraft(s, draft));
+ // We don't store empty discarded drafts and don't need an UNDO then.
+ if (draft.message?.trim()) {
+ fire(document, 'show-alert', {
+ message: 'Draft Discarded',
+ action: 'Undo',
+ callback: () => this.restoreDraft(draft.id),
+ });
+ }
+ this.report(Interaction.COMMENT_DISCARDED, draft);
+ }
+
+ private report(interaction: Interaction, comment: CommentBasics) {
+ const details = reportingDetails(comment);
+ this.reporting.reportInteraction(interaction, details);
+ }
+
+ private showStartRequest() {
+ this.numPendingDraftRequests += 1;
+ this.updateRequestToast();
+ }
+
+ private showEndRequest() {
+ this.numPendingDraftRequests -= 1;
+ this.updateRequestToast();
+ }
+
+ private handleFailedDraftRequest() {
+ this.numPendingDraftRequests -= 1;
+ this.updateRequestToast(/* requestFailed=*/ true);
+ }
+
+ private updateRequestToast(requestFailed?: boolean) {
+ if (this.numPendingDraftRequests === 0 && !requestFailed) {
+ fireEvent(document, 'hide-alert');
+ return;
+ }
+ const message = getSavingMessage(
+ this.numPendingDraftRequests,
+ requestFailed
+ );
+ this.draftToastTask = debounce(
+ this.draftToastTask,
+ () => {
+ // Note: the event is fired on the body rather than this element because
+ // this element may not be attached by the time this executes, in which
+ // case the event would not bubble.
+ fireAlert(document.body, message);
+ },
+ TOAST_DEBOUNCE_INTERVAL
+ );
+ }
+
+ private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+ return Object.values(this.drafts)
+ .flat()
+ .find(d => d.id === id);
+ }
}
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
index 30fc7cf..f39cad8 100644
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -19,17 +19,28 @@
import {UrlEncodedCommentId} from '../../types/common';
import {DraftInfo} from '../../utils/comment-util';
import './comments-model';
+import {CommentsModel} from './comments-model';
+import {deleteDraft} from './comments-model';
+import {Subscription} from 'rxjs';
+import '../../test/common-test-setup-karma';
import {
- updateStateDeleteDraft,
- _testOnly_getState,
- _testOnly_setState,
-} from './comments-model';
+ createComment,
+ createParsedChange,
+ TEST_NUMERIC_CHANGE_ID,
+} from '../../test/test-data-generators';
+import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {getAppContext} from '../app-context';
+import {
+ GerritView,
+ updateState as updateRouterState,
+} from '../router/router-model';
+import {PathToCommentsInfoMap} from '../../types/common';
suite('comments model tests', () => {
test('updateStateDeleteDraft', () => {
const draft = createDraft();
draft.id = '1' as UrlEncodedCommentId;
- _testOnly_setState({
+ const state = {
comments: {},
robotComments: {},
drafts: {
@@ -38,9 +49,9 @@
portedComments: {},
portedDrafts: {},
discardedDrafts: [],
- });
- updateStateDeleteDraft(draft);
- assert.deepEqual(_testOnly_getState(), {
+ };
+ const output = deleteDraft(state, draft);
+ assert.deepEqual(output, {
comments: {},
robotComments: {},
drafts: {
@@ -52,3 +63,65 @@
});
});
});
+
+suite('change service tests', () => {
+ let subscriptions: Subscription[] = [];
+
+ teardown(() => {
+ for (const s of subscriptions) {
+ s.unsubscribe();
+ }
+ subscriptions = [];
+ });
+
+ test('loads comments', async () => {
+ const model = new CommentsModel(
+ getAppContext().changeModel,
+ getAppContext().restApiService,
+ getAppContext().reportingService
+ );
+ const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+ Promise.resolve({'foo.c': [createComment()]})
+ );
+ const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+ Promise.resolve({})
+ );
+ const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+ Promise.resolve({})
+ );
+ const portedCommentsSpy = stubRestApi('getPortedComments').returns(
+ Promise.resolve({'foo.c': [createComment()]})
+ );
+ const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
+ Promise.resolve({})
+ );
+ let comments: PathToCommentsInfoMap = {};
+ subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
+ let portedComments: PathToCommentsInfoMap = {};
+ subscriptions.push(
+ model.portedComments$.subscribe(c => (portedComments = c ?? {}))
+ );
+
+ updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
+ model.changeModel.updateStateChange(createParsedChange());
+
+ await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
+ await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
+ await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
+ await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
+ await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
+ await waitUntil(
+ () => Object.keys(comments).length > 0,
+ 'comment in model not set'
+ );
+ await waitUntil(
+ () => Object.keys(portedComments).length > 0,
+ 'ported comment in model not set'
+ );
+
+ assert.equal(comments['foo.c'].length, 1);
+ assert.equal(comments['foo.c'][0].id, '12345');
+ assert.equal(portedComments['foo.c'].length, 1);
+ assert.equal(portedComments['foo.c'][0].id, '12345');
+ });
+});
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
deleted file mode 100644
index c888cd5..0000000
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {combineLatest, Subscription} from 'rxjs';
-import {NumericChangeId, PatchSetNum, RevisionId} from '../../types/common';
-import {DraftInfo, UIDraft} from '../../utils/comment-util';
-import {fireAlert} from '../../utils/event-util';
-import {CURRENT} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {
- updateStateAddDraft,
- updateStateDeleteDraft,
- updateStateUpdateDraft,
- updateStateComments,
- updateStateRobotComments,
- updateStateDrafts,
- updateStatePortedComments,
- updateStatePortedDrafts,
- updateStateUndoDiscardedDraft,
- discardedDrafts$,
- updateStateReset,
-} from './comments-model';
-import {changeNum$, currentPatchNum$} from '../change/change-model';
-
-import {routerChangeNum$} from '../router/router-model';
-import {Finalizable} from '../registry';
-
-export class CommentsService implements Finalizable {
- private discardedDrafts?: UIDraft[] = [];
-
- private changeNum?: NumericChangeId;
-
- private patchNum?: PatchSetNum;
-
- private readonly reloadListener: () => void;
-
- private readonly subscriptions: Subscription[] = [];
-
- constructor(readonly restApiService: RestApiService) {
- this.subscriptions.push(
- discardedDrafts$.subscribe(
- discardedDrafts => (this.discardedDrafts = discardedDrafts)
- )
- );
- this.subscriptions.push(
- routerChangeNum$.subscribe(changeNum => {
- this.changeNum = changeNum;
- updateStateReset();
- this.reloadAllComments();
- })
- );
- this.subscriptions.push(
- combineLatest([changeNum$, currentPatchNum$]).subscribe(
- ([changeNum, patchNum]) => {
- this.changeNum = changeNum;
- this.patchNum = patchNum;
- this.reloadAllPortedComments();
- }
- )
- );
- this.reloadListener = () => {
- this.reloadAllComments();
- this.reloadAllPortedComments();
- };
- document.addEventListener('reload', this.reloadListener);
- }
-
- finalize() {
- document.removeEventListener('reload', this.reloadListener!);
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
- }
-
- // Note that this does *not* reload ported comments.
- reloadAllComments() {
- if (!this.changeNum) return;
- this.reloadComments(this.changeNum);
- this.reloadRobotComments(this.changeNum);
- this.reloadDrafts(this.changeNum);
- }
-
- reloadAllPortedComments() {
- if (!this.changeNum) return;
- if (!this.patchNum) return;
- this.reloadPortedComments(this.changeNum, this.patchNum);
- this.reloadPortedDrafts(this.changeNum, this.patchNum);
- }
-
- reloadComments(changeNum: NumericChangeId): Promise<void> {
- return this.restApiService
- .getDiffComments(changeNum)
- .then(comments => updateStateComments(comments));
- }
-
- reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
- return this.restApiService
- .getDiffRobotComments(changeNum)
- .then(robotComments => updateStateRobotComments(robotComments));
- }
-
- reloadDrafts(changeNum: NumericChangeId): Promise<void> {
- return this.restApiService
- .getDiffDrafts(changeNum)
- .then(drafts => updateStateDrafts(drafts));
- }
-
- reloadPortedComments(
- changeNum: NumericChangeId,
- patchNum = CURRENT as RevisionId
- ): Promise<void> {
- return this.restApiService
- .getPortedComments(changeNum, patchNum)
- .then(portedComments => updateStatePortedComments(portedComments));
- }
-
- reloadPortedDrafts(
- changeNum: NumericChangeId,
- patchNum = CURRENT as RevisionId
- ): Promise<void> {
- return this.restApiService
- .getPortedDrafts(changeNum, patchNum)
- .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
- }
-
- restoreDraft(
- changeNum: NumericChangeId,
- patchNum: PatchSetNum,
- draftID: string
- ) {
- const draft = {...this.discardedDrafts?.find(d => d.id === draftID)};
- if (!draft) throw new Error('discarded draft not found');
- // delete draft ID since we want to treat this as a new draft creation
- delete draft.id;
- this.restApiService
- .saveDiffDraft(changeNum, patchNum, draft)
- .then(result => {
- if (!result.ok) {
- fireAlert(document, 'Unable to restore draft');
- return;
- }
- this.restApiService.getResponseObject(result).then(obj => {
- const resComment = obj as unknown as DraftInfo;
- resComment.patch_set = draft.patch_set;
- updateStateAddDraft(resComment);
- updateStateUndoDiscardedDraft(draftID);
- });
- });
- }
-
- addDraft(draft: DraftInfo) {
- updateStateAddDraft(draft);
- }
-
- cancelDraft(draft: DraftInfo) {
- updateStateUpdateDraft(draft);
- }
-
- editDraft(draft: DraftInfo) {
- updateStateUpdateDraft(draft);
- }
-
- deleteDraft(draft: DraftInfo) {
- updateStateDeleteDraft(draft);
- }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
deleted file mode 100644
index 5fe859f..0000000
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {Subscription} from 'rxjs';
-import '../../test/common-test-setup-karma';
-import {
- createComment,
- createParsedChange,
- TEST_NUMERIC_CHANGE_ID,
-} from '../../test/test-data-generators';
-import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
-import {getAppContext} from '../app-context';
-import {CommentsService} from './comments-service';
-import {updateStateChange} from '../change/change-model';
-import {
- GerritView,
- updateState as updateRouterState,
-} from '../router/router-model';
-import {comments$, portedComments$} from './comments-model';
-import {PathToCommentsInfoMap} from '../../types/common';
-
-suite('change service tests', () => {
- let subscriptions: Subscription[] = [];
-
- teardown(() => {
- for (const s of subscriptions) {
- s.unsubscribe();
- }
- subscriptions = [];
- });
-
- test('loads comments', async () => {
- new CommentsService(getAppContext().restApiService);
- const diffCommentsSpy = stubRestApi('getDiffComments').returns(
- Promise.resolve({'foo.c': [createComment()]})
- );
- const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
- Promise.resolve({})
- );
- const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
- Promise.resolve({})
- );
- const portedCommentsSpy = stubRestApi('getPortedComments').returns(
- Promise.resolve({'foo.c': [createComment()]})
- );
- const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
- Promise.resolve({})
- );
- let comments: PathToCommentsInfoMap = {};
- subscriptions.push(comments$.subscribe(c => (comments = c ?? {})));
- let portedComments: PathToCommentsInfoMap = {};
- subscriptions.push(
- portedComments$.subscribe(c => (portedComments = c ?? {}))
- );
-
- updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
- updateStateChange(createParsedChange());
-
- await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
- await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
- await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
- await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
- await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
- await waitUntil(
- () => Object.keys(comments).length > 0,
- 'comment in model not set'
- );
- await waitUntil(
- () => Object.keys(portedComments).length > 0,
- 'ported comment in model not set'
- );
-
- assert.equal(comments['foo.c'].length, 1);
- assert.equal(comments['foo.c'][0].id, '12345');
- assert.equal(portedComments['foo.c'].length, 1);
- assert.equal(portedComments['foo.c'][0].id, '12345');
- });
-});
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
index c0e6028..91a77b8 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -19,7 +19,7 @@
import {switchMap} from 'rxjs/operators';
import {Finalizable} from '../registry';
import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {repo$} from '../change/change-model';
+import {ChangeModel} from '../change/change-model';
import {select} from '../../utils/observable-util';
export interface ConfigState {
@@ -43,6 +43,11 @@
configState => configState.repoConfig
);
+ public repoCommentLinks$ = select(
+ this.repoConfig$,
+ repoConfig => repoConfig?.commentlinks ?? {}
+ );
+
public serverConfig$ = select(
this.privateState$,
configState => configState.serverConfig
@@ -50,12 +55,15 @@
private subscriptions: Subscription[];
- constructor(readonly restApiService: RestApiService) {
+ constructor(
+ readonly changeModel: ChangeModel,
+ readonly restApiService: RestApiService
+ ) {
this.subscriptions = [
from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
this.updateServerConfig(config);
}),
- repo$
+ this.changeModel.repo$
.pipe(
switchMap((repo?: RepoName) => {
if (repo === undefined) return of(undefined);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 679fefc..518716b 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -115,11 +115,6 @@
eventName: string | Interaction,
details?: EventDetails
): void;
- /**
- * A draft interaction was started. Update the time-between-draft-actions
- * timer.
- */
- recordDraftInteraction(): void;
reportErrorDialog(message: string): void;
setRepoName(repoName: string): void;
setChangeId(changeId: NumericChangeId): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 7d03de5..a01e9db 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -98,8 +98,6 @@
[Timing.WEB_COMPONENTS_READY]: 0,
};
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
const SLOW_RPC_THRESHOLD = 500;
export function initErrorReporter(reportingService: ReportingService) {
@@ -282,10 +280,6 @@
private reportChangeId: NumericChangeId | undefined;
- private timers: {timeBetweenDraftActions: Timer | null} = {
- timeBetweenDraftActions: null,
- };
-
private pending: PendingReportInfo[] = [];
private slowRpcList: SlowRpcCall[] = [];
@@ -855,27 +849,6 @@
this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
}
- /**
- * A draft interaction was started. Update the time-between-draft-actions
- * Timing.
- */
- recordDraftInteraction() {
- // If there is no timer defined, then this is the first interaction.
- // Set up the timer so that it's ready to record the intervening time when
- // called again.
- const timer = this.timers.timeBetweenDraftActions;
- if (!timer) {
- // Create a timer with a maximum length.
- this.timers.timeBetweenDraftActions = this.getTimer(
- DRAFT_ACTION_TIMER
- ).withMaximum(DRAFT_ACTION_TIMER_MAX);
- return;
- }
-
- // Mark the time and reinitialize the timer.
- timer.end().reset();
- }
-
error(error: Error, errorSource?: string, details?: EventDetails) {
const eventDetails = details ?? {};
const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 485402b..2a5c532 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -59,7 +59,6 @@
pluginLoaded: () => {},
pluginsLoaded: () => {},
pluginsFailed: () => {},
- recordDraftInteraction: () => {},
reporter: () => {},
reportErrorDialog: (message: string) => {
log(`reportErrorDialog: ${message}`);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index f6e87f9..8068dc00 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -282,30 +282,6 @@
assert.isTrue(service.reporter.calledOnce);
});
- test('recordDraftInteraction', () => {
- const key = 'TimeBetweenDraftActions';
- const nowStub = sinon.stub(window.performance, 'now').returns(100);
- const timingStub = sinon.stub(service, '_reportTiming');
- service.recordDraftInteraction();
- assert.isFalse(timingStub.called);
-
- nowStub.returns(200);
- service.recordDraftInteraction();
- assert.isTrue(timingStub.calledOnce);
- assert.equal(timingStub.lastCall.args[0], key);
- assert.equal(timingStub.lastCall.args[1], 100);
-
- nowStub.returns(350);
- service.recordDraftInteraction();
- assert.isTrue(timingStub.calledTwice);
- assert.equal(timingStub.lastCall.args[0], key);
- assert.equal(timingStub.lastCall.args[1], 150);
-
- nowStub.returns(370 + 2 * 60 * 1000);
- service.recordDraftInteraction();
- assert.isFalse(timingStub.calledThrice);
- });
-
test('timeEndWithAverage', () => {
const nowStub = sinon.stub(window.performance, 'now').returns(0);
nowStub.returns(1000);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index ec0cc57..40ef0ed 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -109,6 +109,7 @@
} from '../../types/diff';
import {ParsedChangeInfo} from '../../types/types';
import {ErrorCallback} from '../../api/rest';
+import {DraftInfo} from '../../utils/comment-util';
export type CancelConditionCallback = () => boolean;
@@ -450,21 +451,7 @@
getDiffDrafts(
changeNum: NumericChangeId
- ): Promise<PathToCommentsInfoMap | undefined>;
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum: PatchSetNum,
- patchNum: PatchSetNum,
- path: string
- ): Promise<GetDiffCommentsOutput>;
- getDiffDrafts(
- changeNum: NumericChangeId,
- basePatchNum?: BasePatchSetNum,
- patchNum?: PatchSetNum,
- path?: string
- ):
- | Promise<GetDiffCommentsOutput>
- | Promise<PathToCommentsInfoMap | undefined>;
+ ): Promise<{[path: string]: DraftInfo[]} | undefined>;
createGroup(config: GroupInput & {name: string}): Promise<Response>;
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 8f14077..e61ab15 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -119,7 +119,7 @@
);
this.keydownListener = (e: KeyboardEvent) => {
if (!isComboKey(e.key)) return;
- if (this.shouldSuppress(e)) return;
+ if (this.shortcutsDisabled || shouldSuppress(e)) return;
this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
};
document.addEventListener('keydown', this.keydownListener);
@@ -159,7 +159,12 @@
addShortcut(
element: HTMLElement,
shortcut: Binding,
- listener: (e: KeyboardEvent) => void
+ listener: (e: KeyboardEvent) => void,
+ options: {
+ shouldSuppress: boolean;
+ } = {
+ shouldSuppress: true,
+ }
) {
const wrappedListener = (e: KeyboardEvent) => {
if (e.repeat && !shortcut.allowRepeat) return;
@@ -169,19 +174,21 @@
} else {
if (this.isInComboKeyMode()) return;
}
- if (this.shouldSuppress(e)) return;
+ if (options.shouldSuppress && shouldSuppress(e)) return;
+ // `shortcutsDisabled` refers to disabling global shortcuts like 'n'. If
+ // `shouldSuppress` is false (e.g.for Ctrl - ENTER), then don't disable
+ // the shortcut.
+ if (options.shouldSuppress && this.shortcutsDisabled) return;
e.preventDefault();
e.stopPropagation();
+ this.reportTriggered(e);
listener(e);
};
element.addEventListener('keydown', wrappedListener);
return () => element.removeEventListener('keydown', wrappedListener);
}
- shouldSuppress(e: KeyboardEvent) {
- if (this.shortcutsDisabled) return true;
- if (shouldSuppress(e)) return true;
-
+ private reportTriggered(e: KeyboardEvent) {
// eg: {key: "k:keydown", ..., from: "gr-diff-view"}
let key = `${e.key}:${e.type}`;
if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -195,7 +202,6 @@
from = e.currentTarget.tagName;
}
this.reporting?.reportInteraction('shortcut-triggered', {key, from});
- return false;
}
createTitle(shortcutName: Shortcut, section: ShortcutSection) {
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 274cb87..7dd3f75 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -21,27 +21,10 @@
ShortcutsService,
} from '../../services/shortcuts/shortcuts-service';
import {Shortcut, ShortcutSection} from './shortcuts-config';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
import {SinonFakeTimers} from 'sinon';
import {Key, Modifier} from '../../utils/dom-util';
import {getAppContext} from '../app-context';
-async function keyEventOn(
- el: HTMLElement,
- callback: (e: KeyboardEvent) => void,
- keyCode = 75,
- key = 'k'
-): Promise<KeyboardEvent> {
- let resolve: (e: KeyboardEvent) => void;
- const promise = new Promise<KeyboardEvent>(r => (resolve = r));
- el.addEventListener('keydown', (e: KeyboardEvent) => {
- callback(e);
- resolve(e);
- });
- MockInteractions.keyDownOn(el, keyCode, null, key);
- return await promise;
-}
-
suite('shortcuts-service tests', () => {
let service: ShortcutsService;
@@ -52,55 +35,6 @@
);
});
- suite('shouldSuppress', () => {
- test('do not suppress shortcut event from <div>', async () => {
- await keyEventOn(document.createElement('div'), e => {
- assert.isFalse(service.shouldSuppress(e));
- });
- });
-
- test('suppress shortcut event from <input>', async () => {
- await keyEventOn(document.createElement('input'), e => {
- assert.isTrue(service.shouldSuppress(e));
- });
- });
-
- test('suppress shortcut event from <textarea>', async () => {
- await keyEventOn(document.createElement('textarea'), e => {
- assert.isTrue(service.shouldSuppress(e));
- });
- });
-
- test('do not suppress shortcut event from checkbox <input>', async () => {
- const inputEl = document.createElement('input');
- inputEl.setAttribute('type', 'checkbox');
- await keyEventOn(inputEl, e => {
- assert.isFalse(service.shouldSuppress(e));
- });
- });
-
- test('suppress shortcut event from children of <gr-overlay>', async () => {
- const overlay = document.createElement('gr-overlay');
- const div = document.createElement('div');
- overlay.appendChild(div);
- await keyEventOn(div, e => {
- assert.isTrue(service.shouldSuppress(e));
- });
- });
-
- test('suppress "enter" shortcut event from <a>', async () => {
- await keyEventOn(document.createElement('a'), e => {
- assert.isFalse(service.shouldSuppress(e));
- });
- await keyEventOn(
- document.createElement('a'),
- e => assert.isTrue(service.shouldSuppress(e)),
- 13,
- 'enter'
- );
- });
- });
-
test('getShortcut', () => {
assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 0b6ef6b..9107b31d 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -14,14 +14,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import {from, of, BehaviorSubject, Observable, Subscription} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {
DiffPreferencesInfo as DiffPreferencesInfoAPI,
DiffViewMode,
} from '../../api/diff';
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
+import {
+ AccountCapabilityInfo,
+ AccountDetailInfo,
+ PreferencesInfo,
+} from '../../types/common';
import {
createDefaultPreferences,
createDefaultDiffPrefs,
@@ -38,6 +41,7 @@
account?: AccountDetailInfo;
preferences: PreferencesInfo;
diffPreferences: DiffPreferencesInfo;
+ capabilities?: AccountCapabilityInfo;
}
export class UserModel implements Finalizable {
@@ -58,6 +62,14 @@
account => !!account
);
+ readonly capabilities$: Observable<AccountCapabilityInfo | undefined> =
+ select(this.userState$, userState => userState.capabilities);
+
+ readonly isAdmin$: Observable<boolean> = select(
+ this.capabilities$,
+ capabilities => capabilities?.administrateServer ?? false
+ );
+
readonly preferences$: Observable<PreferencesInfo> = select(
this.privateState$,
userState => userState.preferences
@@ -106,6 +118,16 @@
.subscribe((diffPrefs?: DiffPreferencesInfoAPI) => {
this.setDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
}),
+ this.account$
+ .pipe(
+ switchMap(account => {
+ if (!account) return of(undefined);
+ return from(this.restApiService.getAccountCapabilities());
+ })
+ )
+ .subscribe((capabilities?: AccountCapabilityInfo) => {
+ this.setCapabilities(capabilities);
+ }),
];
}
@@ -154,6 +176,11 @@
this.privateState$.next({...current, diffPreferences});
}
+ setCapabilities(capabilities?: AccountCapabilityInfo) {
+ const current = this.privateState$.getValue();
+ this.privateState$.next({...current, capabilities});
+ }
+
private setAccount(account?: AccountDetailInfo) {
const current = this.privateState$.getValue();
this.privateState$.next({...current, account});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index f041354..b8a5e49 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -21,12 +21,11 @@
import '../scripts/bundled-polymer';
import '@polymer/iron-test-helpers/iron-test-helpers';
import './test-router';
-import {
- _testOnlyInitAppContext,
- _testOnlyFinalizeAppContext,
-} from './test-app-context-init';
+import {AppContext, injectAppContext} from '../services/app-context';
+import {Finalizable} from '../services/registry';
+import {createTestAppContext} from './test-app-context-init';
import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
import {
cleanupTestUtils,
getCleanupsCount,
@@ -36,7 +35,6 @@
removeThemeStyles,
} from './test-utils';
import {safeTypesBridge} from '../utils/safe-types-util';
-import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
import {initGlobalVariables} from '../elements/gr-app-global-var-init';
import 'chai/chai';
import {chaiDomDiff} from '@open-wc/semantic-dom-diff';
@@ -47,10 +45,6 @@
import {_testOnly_allTasks} from '../utils/async-util';
import {cleanUpStorage} from '../services/storage/gr-storage_mock';
-import {getAppContext} from '../services/app-context';
-import {_testOnly_resetState as resetChangeState} from '../services/change/change-model';
-import {_testOnly_resetState as resetChecksState} from '../services/checks/checks-model';
-import {_testOnly_resetState as resetCommentsState} from '../services/comments/comments-model';
import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
declare global {
@@ -102,6 +96,7 @@
window.fixture = fixtureImpl;
let testSetupTimestampMs = 0;
+let appContext: AppContext & Finalizable;
setup(() => {
testSetupTimestampMs = new Date().getTime();
@@ -110,18 +105,15 @@
// If the following asserts fails - then window.stub is
// overwritten by some other code.
assert.equal(getCleanupsCount(), 0);
- _testOnlyInitAppContext();
+ appContext = createTestAppContext();
+ injectAppContext(appContext);
// The following calls is nessecary to avoid influence of previously executed
// tests.
- initGlobalVariables();
- _testOnly_initGerritPluginApi();
+ initGlobalVariables(appContext);
- resetChangeState();
- resetChecksState();
- resetCommentsState();
resetRouterState();
- const shortcuts = getAppContext().shortcutsService;
+ const shortcuts = appContext.shortcutsService;
assert.isTrue(shortcuts._testOnly_isEmpty());
const selection = document.getSelection();
if (selection) {
@@ -217,7 +209,7 @@
cancelAllTasks();
cleanUpStorage();
// Reset state
- _testOnlyFinalizeAppContext();
+ appContext?.finalize();
const testTeardownTimestampMs = new Date().getTime();
const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 8bcd395b..01d776e 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -18,25 +18,23 @@
// Init app context before any other imports
import {create, Registry, Finalizable} from '../services/registry';
import {assertIsDefined} from '../utils/common-util';
-import {AppContext, injectAppContext} from '../services/app-context';
+import {AppContext} from '../services/app-context';
import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
import {grRestApiMock} from './mocks/gr-rest-api_mock';
import {grStorageMock} from '../services/storage/gr-storage_mock';
import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
import {FlagsServiceImplementation} from '../services/flags/flags_impl';
import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeService} from '../services/change/change-service';
-import {ChecksService} from '../services/checks/checks-service';
+import {ChangeModel} from '../services/change/change-model';
+import {ChecksModel} from '../services/checks/checks-model';
import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
import {UserModel} from '../services/user/user-model';
-import {CommentsService} from '../services/comments/comments-service';
+import {CommentsModel} from '../services/comments/comments-model';
import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
import {BrowserModel} from '../services/browser/browser-model';
import {ConfigModel} from '../services/config/config-model';
-let appContext: (AppContext & Finalizable) | undefined;
-
-export function _testOnlyInitAppContext() {
+export function createTestAppContext(): AppContext & Finalizable {
const appRegistry: Registry<AppContext> = {
flagsService: (_ctx: Partial<AppContext>) =>
new FlagsServiceImplementation(),
@@ -47,17 +45,24 @@
return new GrAuthMock(ctx.eventEmitter);
},
restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
- changeService: (ctx: Partial<AppContext>) => {
+ changeModel: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.restApiService, 'restApiService');
- return new ChangeService(ctx.restApiService!);
+ return new ChangeModel(ctx.restApiService!);
},
- commentsService: (ctx: Partial<AppContext>) => {
+ commentsModel: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.changeModel, 'changeModel');
assertIsDefined(ctx.restApiService, 'restApiService');
- return new CommentsService(ctx.restApiService!);
- },
- checksService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.reportingService, 'reportingService');
- return new ChecksService(ctx.reportingService!);
+ return new CommentsModel(
+ ctx.changeModel!,
+ ctx.restApiService!,
+ ctx.reportingService!
+ );
+ },
+ checksModel: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.changeModel, 'changeModel');
+ assertIsDefined(ctx.reportingService, 'reportingService');
+ return new ChecksModel(ctx.changeModel!, ctx.reportingService!);
},
jsApiService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.reportingService, 'reportingService');
@@ -65,8 +70,9 @@
},
storageService: (_ctx: Partial<AppContext>) => grStorageMock,
configModel: (ctx: Partial<AppContext>) => {
+ assertIsDefined(ctx.changeModel, 'changeModel');
assertIsDefined(ctx.restApiService, 'restApiService');
- return new ConfigModel(ctx.restApiService!);
+ return new ConfigModel(ctx.changeModel!, ctx.restApiService!);
},
userModel: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.restApiService, 'restApiService');
@@ -82,11 +88,5 @@
return new BrowserModel(ctx.userModel!);
},
};
- appContext = create<AppContext>(appRegistry);
- injectAppContext(appContext);
-}
-
-export function _testOnlyFinalizeAppContext() {
- appContext?.finalize();
- appContext = undefined;
+ return create<AppContext>(appRegistry);
}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 6e7c8f4..da4601b 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -14,7 +14,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import {
AccountDetailInfo,
AccountId,
@@ -31,6 +30,7 @@
ChangeMessageId,
ChangeMessageInfo,
ChangeViewChangeInfo,
+ CommentInfo,
CommentLinkInfo,
CommentLinks,
CommitId,
@@ -62,6 +62,9 @@
RequirementType,
Reviewers,
RevisionInfo,
+ RobotCommentInfo,
+ RobotId,
+ RobotRunId,
SchemesInfoMap,
ServerInfo,
SubmittedTogetherInfo,
@@ -96,10 +99,9 @@
import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
import {WebLinkInfo} from '../types/diff';
import {
+ CommentThread,
createCommentThreads,
- UIComment,
- UIDraft,
- UIHuman,
+ DraftInfo,
} from '../utils/comment-util';
import {GerritView} from '../services/router/router-model';
import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
@@ -504,7 +506,9 @@
};
}
-export function createComment(): UIHuman {
+export function createComment(
+ extra: Partial<CommentInfo | DraftInfo> = {}
+): CommentInfo {
return {
patch_set: 1 as PatchSetNum,
id: '12345' as UrlEncodedCommentId,
@@ -514,15 +518,28 @@
updated: '2018-02-13 22:48:48.018000000' as Timestamp,
unresolved: false,
path: 'abc.txt',
+ ...extra,
};
}
-export function createDraft(): UIDraft {
+export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
return {
...createComment(),
- collapsed: false,
__draft: true,
- __editing: false,
+ ...extra,
+ };
+}
+
+export function createRobotComment(
+ extra: Partial<CommentInfo> = {}
+): RobotCommentInfo {
+ return {
+ ...createComment(),
+ robot_id: 'robot-id-123' as RobotId,
+ robot_run_id: 'robot-run-id-456' as RobotRunId,
+ properties: {},
+ fix_suggestions: [],
+ ...extra,
};
}
@@ -629,14 +646,27 @@
return new ChangeComments(comments, {}, drafts, {}, {});
}
-export function createCommentThread(comments: UIComment[]) {
+export function createThread(
+ ...comments: Partial<CommentInfo | DraftInfo>[]
+): CommentThread {
+ return {
+ comments: comments.map(c => createComment(c)),
+ rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
+ path: 'test-path-comment-thread',
+ commentSide: CommentSide.REVISION,
+ patchNum: 1 as PatchSetNum,
+ line: 314,
+ };
+}
+
+export function createCommentThread(comments: Array<Partial<CommentInfo>>) {
if (!comments.length) {
throw new Error('comment is required to create a thread');
}
- comments = comments.map(comment => {
+ const filledComments = comments.map(comment => {
return {...createComment(), ...comment};
});
- const threads = createCommentThreads(comments);
+ const threads = createCommentThreads(filledComments);
return threads[0];
}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index f2da972..ebeb1db 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -23,7 +23,7 @@
import {StorageService} from '../services/storage/gr-storage';
import {AuthService} from '../services/gr-auth/gr-auth';
import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {CommentsService} from '../services/comments/comments-service';
+import {CommentsModel} from '../services/comments/comments-model';
import {UserModel} from '../services/user/user-model';
import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
import {queryAndAssert, query} from '../utils/common-util';
@@ -112,8 +112,8 @@
return sinon.spy(getAppContext().restApiService, method);
}
-export function stubComments<K extends keyof CommentsService>(method: K) {
- return sinon.stub(getAppContext().commentsService, method);
+export function stubComments<K extends keyof CommentsModel>(method: K) {
+ return sinon.stub(getAppContext().commentsModel, method);
}
export function stubUsers<K extends keyof UserModel>(method: K) {
@@ -192,13 +192,14 @@
const start = Date.now();
let sleep = 0;
if (predicate()) return Promise.resolve();
+ const error = new Error(message);
return new Promise((resolve, reject) => {
const waiter = () => {
if (predicate()) {
return resolve();
}
if (Date.now() - start >= 1000) {
- return reject(new Error(message));
+ return reject(error);
}
setTimeout(waiter, sleep);
sleep = sleep === 0 ? 1 : sleep * 4;
@@ -218,21 +219,33 @@
* await listenOnce(el, 'render');
* ...
*/
-export function listenOnce(el: EventTarget, eventType: string) {
- return new Promise<void>(resolve => {
- const listener = () => {
+export function listenOnce<T extends Event>(
+ el: EventTarget,
+ eventType: string
+) {
+ return new Promise<T>(resolve => {
+ const listener = (e: Event) => {
removeEventListener();
- resolve();
+ resolve(e as T);
};
- el.addEventListener(eventType, listener);
let removeEventListener = () => {
el.removeEventListener(eventType, listener);
removeEventListener = () => {};
};
+ el.addEventListener(eventType, listener);
registerTestCleanup(removeEventListener);
});
}
+export function dispatch<T>(element: HTMLElement, type: string, detail: T) {
+ const eventOptions = {
+ detail,
+ bubbles: true,
+ composed: true,
+ };
+ element.dispatchEvent(new CustomEvent<T>(type, eventOptions));
+}
+
export function pressKey(
element: HTMLElement,
key: string | Key,
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index fc38756..f58abb3 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -692,9 +692,10 @@
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
*/
export interface CommentInfo {
- // TODO(TS): Make this required.
- patch_set?: PatchSetNum;
id: UrlEncodedCommentId;
+ updated: Timestamp;
+ // TODO(TS): Make this required. Every comment must have patch_set set.
+ patch_set?: PatchSetNum;
path?: string;
side?: CommentSide;
parent?: number;
@@ -702,7 +703,6 @@
range?: CommentRange;
in_reply_to?: UrlEncodedCommentId;
message?: string;
- updated: Timestamp;
author?: AccountInfo;
tag?: string;
unresolved?: boolean;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index f467cf6..4f24535 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import {PatchSetNum} from './common';
-import {UIComment} from '../utils/comment-util';
+import {Comment} from '../utils/comment-util';
import {FetchRequest} from './types';
import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
import {Category, RunStatus} from '../api/checks';
@@ -168,7 +168,7 @@
export interface OpenFixPreviewEventDetail {
patchNum?: PatchSetNum;
- comment?: UIComment;
+ comment?: Comment;
}
export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
@@ -178,7 +178,7 @@
export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
export interface CreateFixCommentEventDetail {
patchNum?: PatchSetNum;
- comment?: UIComment;
+ comment?: Comment;
}
export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
@@ -249,3 +249,12 @@
title: string;
}
export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+/**
+ * This event can be used for Polymer properties that have `notify: true` set.
+ * But it is also generally recommended when you want to notify your parent
+ * elements about a property update, also for Lit elements.
+ *
+ * The name of the event should be `prop-name-changed`.
+ */
+export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5b08fab..966a75c 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -30,84 +30,116 @@
AccountInfo,
AccountDetailInfo,
} from '../types/common';
-import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
+import {CommentSide, SpecialFilePath} from '../constants/constants';
import {parseDate} from './date-util';
-import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
import {isMergeParent, getParentIndex} from './patch-set-util';
import {DiffInfo} from '../types/diff';
+import {LineNumber} from '../api/diff';
export interface DraftCommentProps {
- __draft?: boolean;
- __draftID?: string;
- __date?: Date;
+ // This must be true for all drafts. Drafts received from the backend will be
+ // modified immediately with __draft:true before allowing them to get into
+ // the application state.
+ __draft: boolean;
}
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
- collapsed?: boolean;
+export interface UnsavedCommentProps {
+ // This must be true for all unsaved comment drafts. An unsaved draft is
+ // always just local to a comment component like <gr-comment> or
+ // <gr-comment-thread>. Unsaved drafts will never appear in the application
+ // state.
+ __unsaved: boolean;
}
-export interface UIStateDraftProps {
- __editing?: boolean;
-}
+export type DraftInfo = CommentInfo & DraftCommentProps;
-export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
+export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
+export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
export type CommentMap = {[path: string]: boolean};
-export function isRobot<T extends CommentInfo>(
+export function isRobot<T extends CommentBasics>(
x: T | DraftInfo | RobotCommentInfo | undefined
): x is RobotCommentInfo {
return !!x && !!(x as RobotCommentInfo).robot_id;
}
-export function isDraft<T extends CommentInfo>(
- x: T | UIDraft | undefined
-): x is UIDraft {
- return !!x && !!(x as UIDraft).__draft;
+export function isDraft<T extends CommentBasics>(
+ x: T | DraftInfo | undefined
+): x is DraftInfo {
+ return !!x && !!(x as DraftInfo).__draft;
+}
+
+export function isUnsaved<T extends CommentBasics>(
+ x: T | UnsavedInfo | undefined
+): x is UnsavedInfo {
+ return !!x && !!(x as UnsavedInfo).__unsaved;
+}
+
+export function isDraftOrUnsaved<T extends CommentBasics>(
+ x: T | DraftInfo | UnsavedInfo | undefined
+): x is UnsavedInfo | DraftInfo {
+ return isDraft(x) || isUnsaved(x);
}
interface SortableComment {
- __draft?: boolean;
- __date?: Date;
- updated?: Timestamp;
- id?: UrlEncodedCommentId;
+ updated: Timestamp;
+ id: UrlEncodedCommentId;
}
export function sortComments<T extends SortableComment>(comments: T[]): T[] {
return comments.slice(0).sort((c1, c2) => {
- const d1 = !!c1.__draft;
- const d2 = !!c2.__draft;
+ const d1 = isDraft(c1);
+ const d2 = isDraft(c2);
if (d1 !== d2) return d1 ? 1 : -1;
- const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
- const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+ const date1 = parseDate(c1.updated);
+ const date2 = parseDate(c2.updated);
const dateDiff = date1!.valueOf() - date2!.valueOf();
if (dateDiff !== 0) return dateDiff;
- const id1 = c1.id ?? '';
- const id2 = c2.id ?? '';
+ const id1 = c1.id;
+ const id2 = c2.id;
return id1.localeCompare(id2);
});
}
-export function createCommentThreads(
- comments: UIComment[],
- patchRange?: PatchRange
-) {
+export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+ return {
+ path: thread.path,
+ patch_set: thread.patchNum,
+ side: thread.commentSide ?? CommentSide.REVISION,
+ line: typeof thread.line === 'number' ? thread.line : undefined,
+ range: thread.range,
+ parent: thread.mergeParentNum,
+ message: '',
+ unresolved: true,
+ __unsaved: true,
+ };
+}
+
+export function createUnsavedReply(
+ replyingTo: CommentInfo,
+ message: string,
+ unresolved: boolean
+): UnsavedInfo {
+ return {
+ path: replyingTo.path,
+ patch_set: replyingTo.patch_set,
+ side: replyingTo.side,
+ line: replyingTo.line,
+ range: replyingTo.range,
+ parent: replyingTo.parent,
+ in_reply_to: replyingTo.id,
+ message,
+ unresolved,
+ __unsaved: true,
+ };
+}
+
+export function createCommentThreads(comments: CommentInfo[]) {
const sortedComments = sortComments(comments);
const threads: CommentThread[] = [];
const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -129,7 +161,6 @@
const newThread: CommentThread = {
comments: [comment],
patchNum: comment.patch_set,
- diffSide: Side.LEFT,
commentSide: comment.side ?? CommentSide.REVISION,
mergeParentNum: comment.parent,
path: comment.path,
@@ -137,13 +168,6 @@
range: comment.range,
rootId: comment.id,
};
- if (patchRange) {
- if (isInBaseOfPatchRange(comment, patchRange))
- newThread.diffSide = Side.LEFT;
- else if (isInRevisionOfPatchRange(comment, patchRange))
- newThread.diffSide = Side.RIGHT;
- else throw new Error('comment does not belong in given patchrange');
- }
if (!comment.line && !comment.range) {
newThread.line = 'FILE';
}
@@ -154,68 +178,98 @@
}
export interface CommentThread {
- comments: UIComment[];
+ /**
+ * This can only contain at most one draft. And if so, then it is the last
+ * comment in this list. This must not contain unsaved drafts.
+ */
+ comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
+ /**
+ * Identical to the id of the first comment. If this is undefined, then the
+ * thread only contains an unsaved draft.
+ */
+ rootId?: UrlEncodedCommentId;
path: string;
commentSide: CommentSide;
/* mergeParentNum is the merge parent number only valid for merge commits
when commentSide is PARENT.
mergeParentNum is undefined for auto merge commits
+ Same as `parent` in CommentInfo.
*/
mergeParentNum?: number;
patchNum?: PatchSetNum;
+ /* Different from CommentInfo, which just keeps the line undefined for
+ FILE comments. */
line?: LineNumber;
- /* rootId is optional since we create a empty comment thread element for
- drafts and then create the draft which becomes the root */
- rootId?: UrlEncodedCommentId;
- diffSide?: Side;
range?: CommentRange;
ported?: boolean; // is the comment ported over from a previous patchset
rangeInfoLost?: boolean; // if BE was unable to determine a range for this
}
-export function getLastComment(thread?: CommentThread): UIComment | undefined {
- const len = thread?.comments.length;
- return thread && len ? thread.comments[len - 1] : undefined;
+export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+ const len = thread.comments.length;
+ return thread.comments[len - 1];
}
-export function getFirstComment(thread?: CommentThread): UIComment | undefined {
- return thread?.comments?.[0];
+export function getLastPublishedComment(
+ thread: CommentThread
+): CommentInfo | undefined {
+ const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+ const len = publishedComments.length;
+ return publishedComments[len - 1];
}
-export function countComments(thread?: CommentThread) {
- return thread?.comments?.length ?? 0;
+export function getFirstComment(
+ thread: CommentThread
+): CommentInfo | undefined {
+ return thread.comments[0];
}
-export function isPatchsetLevel(thread?: CommentThread): boolean {
- return thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+export function countComments(thread: CommentThread) {
+ return thread.comments.length;
}
-export function isUnresolved(thread?: CommentThread): boolean {
+export function isPatchsetLevel(thread: CommentThread): boolean {
+ return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function isUnresolved(thread: CommentThread): boolean {
return !isResolved(thread);
}
-export function isResolved(thread?: CommentThread): boolean {
- return !getLastComment(thread)?.unresolved;
+export function isResolved(thread: CommentThread): boolean {
+ const lastUnresolved = getLastComment(thread)?.unresolved;
+ return !lastUnresolved ?? false;
}
-export function isDraftThread(thread?: CommentThread): boolean {
+export function isDraftThread(thread: CommentThread): boolean {
return isDraft(getLastComment(thread));
}
-export function isRobotThread(thread?: CommentThread): boolean {
+export function isRobotThread(thread: CommentThread): boolean {
return isRobot(getFirstComment(thread));
}
-export function hasHumanReply(thread?: CommentThread): boolean {
+export function hasHumanReply(thread: CommentThread): boolean {
return countComments(thread) > 1 && !isRobot(getLastComment(thread));
}
+export function lastUpdated(thread: CommentThread): Date | undefined {
+ // We don't want to re-sort comments when you save a draft reply, so
+ // we stick to the timestampe of the last *published* comment.
+ const lastUpdated =
+ getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated;
+ return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined;
+}
/**
* Whether the given comment should be included in the base side of the
* given patch range.
*/
export function isInBaseOfPatchRange(
- comment: CommentBasics,
+ comment: {
+ patch_set?: PatchSetNum;
+ side?: CommentSide;
+ parent?: number;
+ },
range: PatchRange
) {
// If the base of the patch range is a parent of a merge, and the comment
@@ -249,7 +303,10 @@
* given patch range.
*/
export function isInRevisionOfPatchRange(
- comment: CommentBasics,
+ comment: {
+ patch_set?: PatchSetNum;
+ side?: CommentSide;
+ },
range: PatchRange
) {
return (
@@ -271,7 +328,7 @@
}
export function getPatchRangeForCommentUrl(
- comment: UIComment,
+ comment: Comment,
latestPatchNum: RevisionPatchSetNum
) {
if (!comment.patch_set) throw new Error('Missing comment.patch_set');
@@ -279,7 +336,7 @@
// TODO(dhruvsri): Add handling for comment left on parents of merge commits
if (comment.side === CommentSide.PARENT) {
if (comment.patch_set === ParentPatchSetNum)
- throw new Error('diffSide cannot be PARENT');
+ throw new Error('comment.patch_set cannot be PARENT');
return {
patchNum: comment.patch_set as RevisionPatchSetNum,
basePatchNum: ParentPatchSetNum,
@@ -355,30 +412,46 @@
return authors;
}
-export function computeId(comment: UIComment) {
- if (comment.id) return comment.id;
- if (isDraft(comment)) return comment.__draftID;
- throw new Error('Missing id in root comment.');
-}
-
/**
- * Add path info to every comment as CommentInfo returned
- * from server does not have that.
- *
- * TODO(taoalpha): should consider changing BE to send path
- * back within CommentInfo
+ * Add path info to every comment as CommentInfo returned from server does not
+ * have that.
*/
export function addPath<T>(comments: {[path: string]: T[]} = {}): {
[path: string]: Array<T & {path: string}>;
} {
const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
for (const filePath of Object.keys(comments)) {
- const allCommentsForPath = comments[filePath] || [];
- if (allCommentsForPath.length) {
- updatedComments[filePath] = allCommentsForPath.map(comment => {
- return {...comment, path: filePath};
- });
- }
+ updatedComments[filePath] = (comments[filePath] || []).map(comment => {
+ return {...comment, path: filePath};
+ });
}
return updatedComments;
}
+
+/**
+ * Add __draft:true to all drafts returned from server so that they can be told
+ * apart from published comments easily.
+ */
+export function addDraftProp(
+ draftsByPath: {[path: string]: CommentInfo[]} = {}
+) {
+ const updated: {[path: string]: DraftInfo[]} = {};
+ for (const filePath of Object.keys(draftsByPath)) {
+ updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
+ return {...draft, __draft: true};
+ });
+ }
+ return updated;
+}
+
+export function reportingDetails(comment: CommentBasics) {
+ return {
+ id: comment?.id,
+ message_length: comment?.message?.trim().length,
+ in_reply_to: comment?.in_reply_to,
+ unresolved: comment?.unresolved,
+ path_length: comment?.path?.length,
+ line: comment?.range?.start_line ?? comment?.line,
+ unsaved: isUnsaved(comment),
+ };
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 3c8f26d..f5a2177 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -23,9 +23,8 @@
sortComments,
} from './comment-util';
import {createComment, createCommentThread} from '../test/test-data-generators';
-import {CommentSide, Side} from '../constants/constants';
+import {CommentSide} from '../constants/constants';
import {
- BasePatchSetNum,
ParentPatchSetNum,
PatchSetNum,
RevisionPatchSetNum,
@@ -37,7 +36,6 @@
test('isUnresolved', () => {
const thread = createCommentThread([createComment()]);
- assert.isFalse(isUnresolved(undefined));
assert.isFalse(isUnresolved(thread));
assert.isTrue(
@@ -97,7 +95,6 @@
{
id: 'new_draft' as UrlEncodedCommentId,
message: 'i do not like either of you',
- diffSide: Side.LEFT,
__draft: true,
updated: '2015-12-20 15:01:20.396000000' as Timestamp,
},
@@ -106,13 +103,11 @@
message: 'i like you, jack',
updated: '2015-12-23 15:00:20.396000000' as Timestamp,
line: 1,
- diffSide: Side.LEFT,
},
{
id: 'jacks_reply' as UrlEncodedCommentId,
message: 'i like you, too',
updated: '2015-12-24 15:01:20.396000000' as Timestamp,
- diffSide: Side.LEFT,
line: 1,
in_reply_to: 'sallys_confession',
},
@@ -153,21 +148,16 @@
},
];
- const actualThreads = createCommentThreads(comments, {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 4 as RevisionPatchSetNum,
- });
+ const actualThreads = createCommentThreads(comments);
assert.equal(actualThreads.length, 2);
- assert.equal(actualThreads[0].diffSide, Side.LEFT);
assert.equal(actualThreads[0].comments.length, 2);
assert.deepEqual(actualThreads[0].comments[0], comments[0]);
assert.deepEqual(actualThreads[0].comments[1], comments[1]);
assert.equal(actualThreads[0].patchNum, 1 as PatchSetNum);
assert.equal(actualThreads[0].line, 1);
- assert.equal(actualThreads[1].diffSide, Side.LEFT);
assert.equal(actualThreads[1].comments.length, 1);
assert.deepEqual(actualThreads[1].comments[0], comments[2]);
assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
@@ -194,7 +184,6 @@
const expectedThreads = [
{
- diffSide: Side.LEFT,
commentSide: CommentSide.REVISION,
path: '/p',
rootId: 'betsys_confession' as UrlEncodedCommentId,
@@ -226,13 +215,7 @@
},
];
- assert.deepEqual(
- createCommentThreads(comments, {
- basePatchNum: 5 as BasePatchSetNum,
- patchNum: 10 as RevisionPatchSetNum,
- }),
- expectedThreads
- );
+ assert.deepEqual(createCommentThreads(comments), expectedThreads);
});
test('does not thread unrelated comments at same location', () => {
@@ -241,14 +224,12 @@
id: 'sallys_confession' as UrlEncodedCommentId,
message: 'i like you, jack',
updated: '2015-12-23 15:00:20.396000000' as Timestamp,
- diffSide: Side.LEFT,
path: '/p',
},
{
id: 'jacks_reply' as UrlEncodedCommentId,
message: 'i like you, too',
updated: '2015-12-24 15:01:20.396000000' as Timestamp,
- diffSide: Side.LEFT,
path: '/p',
},
];
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index c2991bf..b96ebe6 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -413,6 +413,12 @@
return addShortcut(document.body, shortcut, listener, options);
}
+/**
+ * Deprecated.
+ *
+ * For LitElement use the shortcut-controller.
+ * For PolymerElement use the keyboard-shortcut-mixin.
+ */
export function addShortcut(
element: HTMLElement,
shortcut: Binding,
diff --git a/polygerrit-ui/app/utils/math-util.ts b/polygerrit-ui/app/utils/math-util.ts
new file mode 100644
index 0000000..adec7d3
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Returns a random integer between `from` and `to`, both included.
+ * So getRandomInt(0, 2) returns 0, 1, or 2 each with probability 1/3.
+ */
+export function getRandomInt(from: number, to: number) {
+ return Math.floor(Math.random() * (to + 1 - from) + from);
+}
diff --git a/polygerrit-ui/app/utils/math-util_test.ts b/polygerrit-ui/app/utils/math-util_test.ts
new file mode 100644
index 0000000..fca1d73
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util_test.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../test/common-test-setup-karma';
+import {getRandomInt} from './math-util';
+
+suite('math-util tests', () => {
+ test('getRandomInt', () => {
+ let r = 0;
+ const randomStub = sinon.stub(Math, 'random').callsFake(() => r);
+
+ assert.equal(getRandomInt(0, 0), 0);
+ assert.equal(getRandomInt(0, 2), 0);
+ assert.equal(getRandomInt(0, 100), 0);
+ assert.equal(getRandomInt(10, 10), 10);
+ assert.equal(getRandomInt(10, 12), 10);
+ assert.equal(getRandomInt(10, 100), 10);
+
+ r = 0.999;
+ assert.equal(getRandomInt(0, 0), 0);
+ assert.equal(getRandomInt(0, 2), 2);
+ assert.equal(getRandomInt(0, 100), 100);
+ assert.equal(getRandomInt(10, 10), 10);
+ assert.equal(getRandomInt(10, 12), 12);
+ assert.equal(getRandomInt(10, 100), 100);
+
+ r = 0.5;
+ assert.equal(getRandomInt(0, 0), 0);
+ assert.equal(getRandomInt(0, 2), 1);
+ assert.equal(getRandomInt(0, 100), 50);
+ assert.equal(getRandomInt(10, 10), 10);
+ assert.equal(getRandomInt(10, 12), 11);
+ assert.equal(getRandomInt(10, 100), 55);
+
+ r = 0.0;
+ assert.equal(getRandomInt(0, 2), 0);
+ r = 0.33;
+ assert.equal(getRandomInt(0, 2), 0);
+ r = 0.34;
+ assert.equal(getRandomInt(0, 2), 1);
+ r = 0.66;
+ assert.equal(getRandomInt(0, 2), 1);
+ r = 0.67;
+ assert.equal(getRandomInt(0, 2), 2);
+ r = 0.99;
+ assert.equal(getRandomInt(0, 2), 2);
+
+ randomStub.restore();
+ });
+});