Show respectful review tip when user writes comments
With a lock-up Math.max(3 days, period between creating comments),
we show this tip with a 30% probability.
When show up, it will pick tip randomly from a pre-defined list.
Once dismissed, it will add another 3 more lock up time before showing up again.
Also added interaction reports for it.
Bug: Issue 11441
Change-Id: I6baffb7369bb2dd59716f496c389971dc7050cc8
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
index ea6c0de..6e5eefd 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
@@ -227,6 +227,29 @@
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;
+ }
</style>
<div id="container" class="container">
<div class="header" id="header" on-click="_handleToggleCollapsed">
@@ -289,6 +312,30 @@
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
+ on-click="_onRespectfulReadMoreClick"
+ href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
+ target="_blank">
+ Read more
+ </a>
+ <iron-icon
+ class="close pointer"
+ on-click="_dismissRespectfulTip"
+ icon="gr-icons:close"></iron-icon>
+ </div>
+ </div>
+ </template>
</template>
<!--The message class is needed to ensure selectability from
gr-diff-selection.-->
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index 3f4cc69..91c88af 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -32,6 +32,22 @@
const FILE = 'FILE';
/**
+ * All candidates tips to show, will pick randomly.
+ */
+ const RESPECTFUL_REVIEW_TIPS= [
+ 'DO: Assume competence.',
+ 'DO: Provide rationale or context.',
+ 'DO: Consider how comments may be interpreted.',
+ 'DON’T: Criticize the person.',
+ 'DON’T: Use harsh language.',
+ 'DO: Provide specific and actionable feedback.',
+ 'DO: Clearly mark nitpicks and optional comments.',
+ 'DO: Clarify code or reply to the reviewer’s comment.',
+ 'DO: When disagreeing with feedback, explain the advantage' +
+ ' of your approach.',
+ ];
+
+ /**
* @appliesMixin Gerrit.FireMixin
* @appliesMixin Gerrit.KeyboardShortcutMixin
* @extends Polymer.Element
@@ -167,6 +183,16 @@
type: Object,
value: () => { return {}; },
},
+
+ _showRespectfulTip: {
+ type: Boolean,
+ value: false,
+ },
+ _respectfulReviewTip: String,
+ _respectfulTipDismissed: {
+ type: Boolean,
+ value: false,
+ },
};
}
@@ -177,6 +203,7 @@
'_isRobotComment(comment)',
'_calculateActionstoShow(showActions, isRobotComment)',
'_computeHasHumanReply(comment, comments.*)',
+ '_onEditingChange(editing)',
];
}
@@ -209,6 +236,50 @@
}
}
+ _onEditingChange(editing) {
+ if (!editing) return;
+ // 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];
+ this.$.reporting.reportInteraction(
+ 'respectful-tip-appeared',
+ {tip: this._respectfulReviewTip}
+ );
+ // update cache
+ this.$.storage.setRespectfulTipVisibility();
+ }
+ }
+
+ /** Set as a separate method so easy to stub. */
+ getRandomNum(min, max) {
+ return Math.floor(Math.random() * (max - min) + min);
+ }
+
+ _computeVisibilityOfTip(showTip, tipDismissed) {
+ return showTip && !tipDismissed;
+ }
+
+ _dismissRespectfulTip() {
+ this._respectfulTipDismissed = true;
+ this.$.reporting.reportInteraction(
+ 'respectful-tip-dismissed',
+ {tip: this._respectfulReviewTip}
+ );
+ // add a 3 day delay to the tip cache
+ this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 3);
+ }
+
+ _onRespectfulReadMoreClick() {
+ this.$.reporting.reportInteraction('respectful-read-more-clicked');
+ }
+
get textarea() {
return this.shadowRoot.querySelector('#editTextarea');
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
index 8bc9518..6d0a5e5 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
@@ -1031,5 +1031,153 @@
MockInteractions.tap(element.$$('.show-fix'));
});
});
+
+ suite('respectful tips', () => {
+ let element;
+ let sandbox;
+ let clock;
+ setup(() => {
+ stub('gr-rest-api-interface', {
+ getAccount() { return Promise.resolve(null); },
+ });
+ clock = sinon.useFakeTimers();
+ sandbox = sinon.sandbox.create();
+ });
+
+ teardown(() => {
+ clock.restore();
+ sandbox.restore();
+ });
+
+ test('show tip when no cached record', done => {
+ // fake stub for storage
+ const respectfulGetStub = sinon.stub();
+ const respectfulSetStub = sinon.stub();
+ stub('gr-storage', {
+ getRespectfulTipVisibility() { return respectfulGetStub(); },
+ setRespectfulTipVisibility() { return respectfulSetStub(); },
+ });
+ respectfulGetStub.returns(null);
+ element = fixture('draft');
+ // fake random
+ element.getRandomNum = () => 0;
+ element.comment = {__editing: true};
+ flush(() => {
+ assert.isTrue(respectfulGetStub.called);
+ assert.isTrue(respectfulSetStub.called);
+ assert.isTrue(
+ !!element.shadowRoot.querySelector('.respectfulReviewTip')
+ );
+ done();
+ });
+ });
+
+ test('add 3 day delays once dismissed', done => {
+ // fake stub for storage
+ const respectfulGetStub = sinon.stub();
+ const respectfulSetStub = sinon.stub();
+ stub('gr-storage', {
+ getRespectfulTipVisibility() { return respectfulGetStub(); },
+ setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
+ });
+ respectfulGetStub.returns(null);
+ element = fixture('draft');
+ // fake random
+ element.getRandomNum = () => 0;
+ element.comment = {__editing: true};
+ flush(() => {
+ assert.isTrue(respectfulGetStub.called);
+ assert.isTrue(respectfulSetStub.called);
+ assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
+ assert.isTrue(
+ !!element.shadowRoot.querySelector('.respectfulReviewTip')
+ );
+
+ MockInteractions.tap(element.shadowRoot
+ .querySelector('.respectfulReviewTip .close'));
+ flushAsynchronousOperations();
+ assert.isTrue(respectfulSetStub.lastCall.args[0] === 3);
+ done();
+ });
+ });
+
+ test('do not show tip when fall out of probability', done => {
+ // fake stub for storage
+ const respectfulGetStub = sinon.stub();
+ const respectfulSetStub = sinon.stub();
+ stub('gr-storage', {
+ getRespectfulTipVisibility() { return respectfulGetStub(); },
+ setRespectfulTipVisibility() { return respectfulSetStub(); },
+ });
+ respectfulGetStub.returns(null);
+ element = fixture('draft');
+ // fake random
+ element.getRandomNum = () => 3;
+ element.comment = {__editing: true};
+ flush(() => {
+ assert.isTrue(respectfulGetStub.called);
+ assert.isFalse(respectfulSetStub.called);
+ assert.isFalse(
+ !!element.shadowRoot.querySelector('.respectfulReviewTip')
+ );
+ done();
+ });
+ });
+
+ test('show tip when editing changed to true', done => {
+ // fake stub for storage
+ const respectfulGetStub = sinon.stub();
+ const respectfulSetStub = sinon.stub();
+ stub('gr-storage', {
+ getRespectfulTipVisibility() { return respectfulGetStub(); },
+ setRespectfulTipVisibility() { return respectfulSetStub(); },
+ });
+ respectfulGetStub.returns(null);
+ element = fixture('draft');
+ // fake random
+ element.getRandomNum = () => 0;
+ element.comment = {__editing: false};
+ flush(() => {
+ assert.isFalse(respectfulGetStub.called);
+ assert.isFalse(respectfulSetStub.called);
+ assert.isFalse(
+ !!element.shadowRoot.querySelector('.respectfulReviewTip')
+ );
+
+ element.editing = true;
+ flush(() => {
+ assert.isTrue(respectfulGetStub.called);
+ assert.isTrue(respectfulSetStub.called);
+ assert.isTrue(
+ !!element.shadowRoot.querySelector('.respectfulReviewTip')
+ );
+ done();
+ });
+ });
+ });
+
+ test('no tip when cached record', done => {
+ // fake stub for storage
+ const respectfulGetStub = sinon.stub();
+ const respectfulSetStub = sinon.stub();
+ stub('gr-storage', {
+ getRespectfulTipVisibility() { return respectfulGetStub(); },
+ setRespectfulTipVisibility() { return respectfulSetStub(); },
+ });
+ respectfulGetStub.returns({});
+ element = fixture('draft');
+ // fake random
+ element.getRandomNum = () => 0;
+ element.comment = {__editing: true};
+ flush(() => {
+ assert.isTrue(respectfulGetStub.called);
+ assert.isFalse(respectfulSetStub.called);
+ assert.isFalse(
+ !!element.shadowRoot.querySelector('.respectfulReviewTip')
+ );
+ done();
+ });
+ });
+ });
});
</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index d36ce3b..fb8f5f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -60,6 +60,8 @@
<g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"/><path d="M0 0h24v24H0z" fill="none"/></g>
<!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
<g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
+ <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+ <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></path></g>
<!-- This is a custom PolyGerrit SVG -->
<g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"/></g>
<!-- This is a custom PolyGerrit SVG -->
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 53311b5..8cc9de9 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -17,16 +17,17 @@
(function() {
'use strict';
- // Date cutoff is one day:
- const CLEANUP_MAX_AGE = 24 * 60 * 60 * 1000;
+ const DURATION_DAY = 24 * 60 * 60 * 1000;
// Clean up old entries no more frequently than one day.
- const CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
+ const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
- const CLEANUP_PREFIXES = [
- 'draft:',
- 'editablecontent:',
- ];
+ const CLEANUP_PREFIXES_MAX_AGE_MAP = {
+ // respectfultip has a 3 day expiration
+ 'respectfultip:': 3 * DURATION_DAY,
+ 'draft:': DURATION_DAY,
+ 'editablecontent:': DURATION_DAY,
+ };
/** @extends Polymer.Element */
class GrStorage extends Polymer.GestureEventListeners(
@@ -76,6 +77,19 @@
{message, updated: Date.now()});
}
+ getRespectfulTipVisibility() {
+ this._cleanupItems();
+ return this._getObject('respectfultip:visibility');
+ }
+
+ setRespectfulTipVisibility(delayDays = 0) {
+ this._cleanupItems();
+ this._setObject(
+ 'respectfultip:visibility',
+ {updated: Date.now() + delayDays * DURATION_DAY}
+ );
+ }
+
eraseEditableContentItem(key) {
this._storage.removeItem(this._getEditableContentKey(key));
}
@@ -106,18 +120,17 @@
this._lastCleanup = Date.now();
let item;
- for (const key in this._storage) {
- if (!this._storage.hasOwnProperty(key)) { continue; }
- for (const prefix of CLEANUP_PREFIXES) {
+ Object.keys(this._storage).forEach(key => {
+ Object.keys(CLEANUP_PREFIXES_MAX_AGE_MAP).forEach(prefix => {
if (key.startsWith(prefix)) {
item = this._getObject(key);
- if (Date.now() - item.updated > CLEANUP_MAX_AGE) {
+ const expiration = CLEANUP_PREFIXES_MAX_AGE_MAP[prefix];
+ if (Date.now() - item.updated > expiration) {
this._storage.removeItem(key);
}
- break;
}
- }
- }
+ });
+ });
}
_getObject(key) {