Refactor directory structure of components There is no change in functionality. Only moving things around. + Separate html from the js. + Place the unit test for a component within the same folder. + Organize the components in subfolders. Change-Id: I51fdc510db75fc1b33f040ca63decbbdfd4d5513
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html new file mode 100644 index 0000000..1593fab --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -0,0 +1,84 @@ +<!-- +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. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../bower_components/iron-input/iron-input.html"> +<link rel="import" href="../../../behaviors/rest-client-behavior.html"> + +<link rel="import" href="../../shared/gr-ajax/gr-ajax.html"> +<link rel="import" href="../../shared/gr-button/gr-button.html"> +<link rel="import" href="../../shared/gr-overlay/gr-overlay.html"> +<link rel="import" href="../../shared/gr-request/gr-request.html"> + +<link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html"> + +<dom-module id="gr-change-actions"> + <template> + <style> + :host { + display: block; + } + gr-button { + display: block; + margin-bottom: .5em; + } + gr-button:before { + content: attr(data-label); + } + gr-button[loading]:before { + content: attr(data-loading-label); + } + @media screen and (max-width: 50em) { + .confirmDialog { + width: 90vw; + } + } + </style> + <gr-ajax id="actionsXHR" + url="[[_computeRevisionActionsPath(changeNum, patchNum)]]" + last-response="{{_revisionActions}}" + loading="{{_loading}}"></gr-ajax> + <div> + <template is="dom-repeat" items="[[_computeActionValues(actions, 'change')]]" as="action"> + <gr-button title$="[[action.title]]" + primary$="[[_computePrimary(action.__key)]]" + hidden$="[[!action.enabled]]" + data-action-key$="[[action.__key]]" + data-action-type$="[[action.__type]]" + data-label$="[[action.label]]" + on-tap="_handleActionTap"></gr-button> + </template> + <template is="dom-repeat" items="[[_computeActionValues(_revisionActions, 'revision')]]" as="action"> + <gr-button title$="[[action.title]]" + primary$="[[_computePrimary(action.__key)]]" + disabled$="[[!action.enabled]]" + data-action-key$="[[action.__key]]" + data-action-type$="[[action.__type]]" + data-label$="[[action.label]]" + data-loading-label$="[[_computeLoadingLabel(action.__key)]]" + on-tap="_handleActionTap"></gr-button> + </template> + </div> + <gr-overlay id="overlay" with-backdrop> + <gr-confirm-rebase-dialog id="confirmRebase" + class="confirmDialog" + on-confirm="_handleRebaseConfirm" + on-cancel="_handleConfirmDialogCancel" + hidden></gr-confirm-rebase-dialog> + </gr-overlay> + </template> + <script src="gr-change-actions.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js new file mode 100644 index 0000000..a89c0aa --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -0,0 +1,225 @@ +// 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. +(function() { + 'use strict'; + + // TODO(davido): Add the rest of the change actions. + var ChangeActions = { + ABANDON: 'abandon', + DELETE: '/', + RESTORE: 'restore', + }; + + // TODO(andybons): Add the rest of the revision actions. + var RevisionActions = { + DELETE: '/', + PUBLISH: 'publish', + REBASE: 'rebase', + SUBMIT: 'submit', + }; + + Polymer({ + is: 'gr-change-actions', + + /** + * Fired when the change should be reloaded. + * + * @event reload-change + */ + + properties: { + actions: { + type: Object, + }, + changeNum: String, + patchNum: String, + _loading: { + type: Boolean, + value: true, + }, + _revisionActions: Object, + }, + + behaviors: [ + Gerrit.RESTClientBehavior, + ], + + observers: [ + '_actionsChanged(actions, _revisionActions)', + ], + + reload: function() { + if (!this.changeNum || !this.patchNum) { + return Promise.resolve(); + } + return this.$.actionsXHR.generateRequest().completes; + }, + + _actionsChanged: function(actions, revisionActions) { + this.hidden = + revisionActions.rebase == null && + revisionActions.submit == null && + revisionActions.publish == null && + actions.abandon == null && + actions.restore == null; + }, + + _computeRevisionActionsPath: function(changeNum, patchNum) { + return this.changeBaseURL(changeNum, patchNum) + '/actions'; + }, + + _getValuesFor: function(obj) { + return Object.keys(obj).map(function(key) { + return obj[key]; + }); + }, + + _computeActionValues: function(actions, type) { + var result = []; + var values = this._getValuesFor( + type == 'change' ? ChangeActions : RevisionActions); + for (var a in actions) { + if (values.indexOf(a) == -1) { continue; } + actions[a].__key = a; + actions[a].__type = type; + result.push(actions[a]); + } + return result; + }, + + _computeLoadingLabel: function(action) { + return { + 'rebase': 'Rebasing...', + 'submit': 'Submitting...', + }[action]; + }, + + _computePrimary: function(actionKey) { + return actionKey == 'submit'; + }, + + _computeButtonClass: function(action) { + if ([RevisionActions.SUBMIT, + RevisionActions.PUBLISH].indexOf(action) != -1) { + return 'primary'; + } + return ''; + }, + + _handleActionTap: function(e) { + e.preventDefault(); + var el = Polymer.dom(e).rootTarget; + var key = el.getAttribute('data-action-key'); + var type = el.getAttribute('data-action-type'); + if (type == 'revision') { + if (key == RevisionActions.REBASE) { + this._showRebaseDialog(); + return; + } + this._fireRevisionAction(this._prependSlash(key), + this._revisionActions[key]); + } else { + this._fireChangeAction(this._prependSlash(key), this.actions[key]); + } + }, + + _prependSlash: function(key) { + return key == '/' ? key : '/' + key; + }, + + _handleConfirmDialogCancel: function() { + var dialogEls = + Polymer.dom(this.root).querySelectorAll('.confirmDialog'); + for (var i = 0; i < dialogEls.length; i++) { + dialogEls[i].hidden = true; + } + this.$.overlay.close(); + }, + + _handleRebaseConfirm: function() { + var payload = {}; + var el = this.$.confirmRebase; + if (el.clearParent) { + // There is a subtle but important difference between setting the base + // to an empty string and omitting it entirely from the payload. An + // empty string implies that the parent should be cleared and the + // change should be rebased on top of the target branch. Leaving out + // the base implies that it should be rebased on top of its current + // parent. + payload.base = ''; + } else if (el.base && el.base.length > 0) { + payload.base = el.base; + } + this.$.overlay.close(); + el.hidden = false; + this._fireRevisionAction('/rebase', this._revisionActions.rebase, + payload); + }, + + _fireChangeAction: function(endpoint, action) { + this._send(action.method, {}, endpoint).then( + function() { + // We can’t reload a change that was deleted. + if (endpoint == ChangeActions.DELETE) { + page.show('/'); + } else { + this.fire('reload-change', null, {bubbles: false}); + } + }.bind(this)).catch(function(err) { + alert('Oops. Something went wrong. Check the console and bug the ' + + 'PolyGerrit team for assistance.'); + throw err; + }); + }, + + _fireRevisionAction: function(endpoint, action, opt_payload) { + var buttonEl = this.$$('[data-action-key="' + action.__key + '"]'); + buttonEl.setAttribute('loading', true); + buttonEl.disabled = true; + function enableButton() { + buttonEl.removeAttribute('loading'); + buttonEl.disabled = false; + } + + this._send(action.method, opt_payload, endpoint, true).then( + function() { + this.fire('reload-change', null, {bubbles: false}); + enableButton(); + }.bind(this)).catch(function(err) { + // TODO(andybons): Handle merge conflict (409 status); + alert('Oops. Something went wrong. Check the console and bug the ' + + 'PolyGerrit team for assistance.'); + enableButton(); + throw err; + }); + }, + + _showRebaseDialog: function() { + this.$.confirmRebase.hidden = false; + this.$.overlay.open(); + }, + + _send: function(method, payload, actionEndpoint, revisionAction) { + var xhr = document.createElement('gr-request'); + this._xhrPromise = xhr.send({ + method: method, + url: this.changeBaseURL(this.changeNum, + revisionAction ? this.patchNum : null) + actionEndpoint, + body: payload, + }); + + return this._xhrPromise; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html new file mode 100644 index 0000000..a89a7a5 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -0,0 +1,155 @@ +<!DOCTYPE html> +<!-- +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. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-change-actions</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../scripts/util.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-change-actions.html"> + +<test-fixture id="basic"> + <template> + <gr-change-actions></gr-change-actions> + </template> +</test-fixture> + +<script> + suite('gr-change-actions tests', function() { + var element; + var server; + + setup(function() { + element = fixture('basic'); + server = sinon.fakeServer.create(); + + server.respondWith( + 'GET', + '/changes/42/revisions/2/actions', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + JSON.stringify({ + cherrypick: { + method: 'POST', + label: 'Cherry Pick', + title: 'Cherry pick change to a different branch', + enabled: true + }, + rebase: { + method: 'POST', + label: 'Rebase', + title: 'Rebase onto tip of branch or parent change' + }, + submit: { + method: 'POST', + label: 'Submit', + title: 'Submit patch set 1 into master', + enabled: true + } + }), + ] + ); + + server.respondWith( + 'POST', + '/changes/42/revisions/2/submit', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n{}', // The response is not used by the element. + ] + ); + + server.respondWith( + 'POST', + '/changes/42/revisions/2/rebase', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n{}', // The response is not used by the element. + ] + ); + + element.changeNum = '42'; + element.patchNum = '2'; + element.reload(); + + server.respond(); + }); + + test('submit and rebase buttons show', function(done) { + element.async(function() { + var buttonEls = Polymer.dom(element.root).querySelectorAll('gr-button'); + assert.equal(buttonEls.length, 2); + assert.isFalse(element.hidden); + done(); + }, 1); + }); + + test('submit change', function(done) { + element.async(function() { + var submitButton = element.$$('gr-button[data-action-key="submit"]'); + assert.ok(submitButton); + MockInteractions.tap(submitButton); + server.respond(); + + // Upon success it should fire the reload-change event. + element.addEventListener('reload-change', function(e) { + done(); + }); + }, 1); + }); + + test('rebase change', function(done) { + element.async(function() { + var rebaseButton = element.$$('gr-button[data-action-key="rebase"]'); + MockInteractions.tap(rebaseButton); + + element.$.confirmRebase.base = '1234'; + element._handleRebaseConfirm(); + server.respond(); + var lastRequest = server.requests[server.requests.length - 1]; + assert.equal(lastRequest.requestBody, '{"base":"1234"}'); + + element.$.confirmRebase.base = ''; + element._handleRebaseConfirm(); + server.respond(); + lastRequest = server.requests[server.requests.length - 1]; + assert.equal(lastRequest.requestBody, '{}'); + + element.$.confirmRebase.base = 'does not matter'; + element.$.confirmRebase.clearParent = true; + element._handleRebaseConfirm(); + server.respond(); + lastRequest = server.requests[server.requests.length - 1]; + assert.equal(lastRequest.requestBody, '{"base":""}'); + + // Upon each request success it should fire the reload-change event. + var numEvents = 0; + element.addEventListener('reload-change', function(e) { + if (++numEvents == 3) { done(); } + }); + }, 1); + }); + + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html new file mode 100644 index 0000000..7b1a2f1 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -0,0 +1,127 @@ +<!-- +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. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../behaviors/rest-client-behavior.html"> +<link rel="import" href="../../shared/gr-account-link/gr-account-link.html"> +<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html"> +<link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html"> + +<script src="../../../scripts/fake-app.js"></script> + +<dom-module id="gr-change-metadata"> + <template> + <style> + section:not(:first-of-type) { + margin-top: 1em; + } + .title, + .value { + display: block; + } + .title { + color: #666; + font-weight: bold; + } + .labelValueContainer:not(:first-of-type) { + margin-top: .25em; + } + .labelValueContainer .approved, + .labelValueContainer .notApproved { + display: inline-block; + padding: .1em .3em; + border-radius: 3px; + } + .labelValue { + display: inline-block; + } + .approved { + background-color: #d4ffd4; + } + .notApproved { + background-color: #ffd4d4; + } + @media screen and (max-width: 50em), screen and (min-width: 75em) { + section { + display: flex; + } + section:not(:first-of-type) { + margin-top: .25em; + } + .title { + min-width: 9em; + } + } + </style> + <section> + <span class="title">Updated</span> + <span class="value"> + <gr-date-formatter + date-str="[[change.updated]]"></gr-date-formatter> + </span> + </section> + <section> + <span class="title">Owner</span> + <span class="value"> + <gr-account-link account="[[change.owner]]"></gr-account-link> + </span> + </section> + <section> + <span class="title">Reviewers</span> + <span class="value"> + <gr-reviewer-list + change="[[change]]" + mutable="[[mutable]]" + suggest-from="[[serverConfig.suggest.from]]"></gr-reviewer-list> + </span> + </section> + <section> + <span class="title">Project</span> + <span class="value">[[change.project]]</span> + </section> + <section> + <span class="title">Branch</span> + <span class="value">[[change.branch]]</span> + </section> + <section> + <span class="title">Topic</span> + <span class="value">[[change.topic]]</span> + </section> + <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden> + <span class="title">Strategy</span> + <span class="value">[[_computeStrategy(change)]]</span> + </section> + <template is="dom-repeat" + items="[[_computeLabelNames(change.labels)]]" as="labelName"> + <section> + <span class="title">[[labelName]]</span> + <span class="value"> + <template is="dom-repeat" + items="[[_computeLabelValues(labelName, change.labels)]]" + as="label"> + <div class="labelValueContainer"> + <span class$="[[label.className]]"> + <span class="labelValue">[[label.value]]</span> + <gr-account-link account="[[label.account]]"></gr-account-link> + </span> + </div> + </template> + </span> + </section> + </template> + </template> + <script src="gr-change-metadata.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js new file mode 100644 index 0000000..3d2633b --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -0,0 +1,76 @@ +// 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. +(function() { + 'use strict'; + + var SubmitTypeLabel = { + FAST_FORWARD_ONLY: 'Fast Forward Only', + MERGE_IF_NECESSARY: 'Merge if Necessary', + REBASE_IF_NECESSARY: 'Rebase if Necessary', + MERGE_ALWAYS: 'Always Merge', + CHERRY_PICK: 'Cherry Pick', + }; + + Polymer({ + is: 'gr-change-metadata', + + properties: { + change: Object, + mutable: Boolean, + }, + + behaviors: [ + Gerrit.RESTClientBehavior, + ], + + _computeHideStrategy: function(change) { + var open = change.status == this.ChangeStatus.NEW || + change.status == this.ChangeStatus.DRAFT; + return !open; + }, + + _computeStrategy: function(change) { + return SubmitTypeLabel[change.submit_type]; + }, + + _computeLabelNames: function(labels) { + return Object.keys(labels).sort(); + }, + + _computeLabelValues: function(labelName, labels) { + var result = []; + var t = labels[labelName]; + if (!t) { return result; } + var approvals = t.all || []; + approvals.forEach(function(label) { + if (label.value && label.value != labels[labelName].default_value) { + var labelClassName; + var labelValPrefix = ''; + if (label.value > 0) { + labelValPrefix = '+'; + labelClassName = 'approved'; + } else if (label.value < 0) { + labelClassName = 'notApproved'; + } + result.push({ + value: labelValPrefix + label.value, + className: labelClassName, + account: label, + }); + } + }); + return result; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html new file mode 100644 index 0000000..6c97b5a --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -0,0 +1,68 @@ +<!DOCTYPE html> +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-change-metadata</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../bower_components/page/page.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-change-metadata.html"> +<script src="../../../scripts/util.js"></script> + +<test-fixture id="basic"> + <template> + <gr-change-metadata></gr-change-metadata> + </template> +</test-fixture> + +<script> + suite('gr-change-metadata tests', function() { + var element; + + setup(function() { + element = fixture('basic'); + }); + + test('computed fields', function() { + assert.isFalse(element._computeHideStrategy({status: 'NEW'})); + assert.isFalse(element._computeHideStrategy({status: 'DRAFT'})); + assert.isTrue(element._computeHideStrategy({status: 'MERGED'})); + assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'})); + assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}), + 'Cherry Pick'); + }); + + test('show strategy for open change', function() { + element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}}; + flushAsynchronousOperations(); + var strategy = element.$$('.strategy'); + assert.ok(strategy); + assert.isFalse(strategy.hasAttribute('hidden')); + assert.equal(strategy.children[1].innerHTML, 'Cherry Pick'); + }); + + test('hide strategy for closed change', function() { + element.change = {status: 'MERGED', labels: {}}; + flushAsynchronousOperations(); + assert.isTrue(element.$$('.strategy').hasAttribute('hidden')); + }); + + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html new file mode 100644 index 0000000..41cb058 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -0,0 +1,319 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html"> +<link rel="import" href="../../../behaviors/rest-client-behavior.html"> +<link rel="import" href="../../shared/gr-account-link/gr-account-link.html"> +<link rel="import" href="../../shared/gr-ajax/gr-ajax.html"> +<link rel="import" href="../../shared/gr-button/gr-button.html"> +<link rel="import" href="../../shared/gr-change-star/gr-change-star.html"> +<link rel="import" href="../../shared/gr-overlay/gr-overlay.html"> +<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html"> +<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html"> + +<link rel="import" href="../gr-change-actions/gr-change-actions.html"> +<link rel="import" href="../gr-change-metadata/gr-change-metadata.html"> +<link rel="import" href="../gr-download-dialog/gr-download-dialog.html"> +<link rel="import" href="../gr-file-list/gr-file-list.html"> +<link rel="import" href="../gr-messages-list/gr-messages-list.html"> +<link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html"> +<link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html"> + +<dom-module id="gr-change-view"> + <template> + <style> + .container { + margin: 1em var(--default-horizontal-margin); + } + .container:not(.loading) { + background-color: var(--view-background-color); + } + .container.loading { + color: #666; + } + .headerContainer { + height: 4.1em; + margin-bottom: .5em; + } + .header { + align-items: center; + background-color: var(--view-background-color); + border-bottom: 1px solid #ddd; + display: flex; + padding: 1em var(--default-horizontal-margin); + z-index: 99; /* Less than gr-overlay's backdrop */ + } + .header.pinned { + border-bottom-color: transparent; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + position: fixed; + top: 0; + transition: box-shadow 250ms linear; + width: calc(100% - (2 * var(--default-horizontal-margin))); + } + .header-title { + flex: 1; + font-size: 1.2em; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + gr-change-star { + margin-right: .25em; + vertical-align: -.425em; + } + .download, + .patchSelectLabel { + margin-left: var(--default-horizontal-margin); + } + .header select { + margin-left: .5em; + } + .header .reply { + margin-left: var(--default-horizontal-margin); + } + gr-reply-dialog { + min-width: 30em; + max-width: 50em; + } + .changeStatus { + color: #999; + text-transform: capitalize; + } + section { + margin: 10px 0; + padding: 10px var(--default-horizontal-margin); + } + /* Strong specificity here is needed due to + https://github.com/Polymer/polymer/issues/2531 */ + .container section.changeInfo { + border-bottom: 1px solid #ddd; + display: flex; + margin-top: 0; + padding-top: 0; + } + .changeInfo-column:not(:last-of-type) { + margin-right: 1em; + padding-right: 1em; + } + .changeMetadata { + border-right: 1px solid #ddd; + font-size: .9em; + } + gr-change-actions { + margin-top: 1em; + } + .commitMessage { + font-family: var(--monospace-font-family); + flex: 0 0 72ch; + margin-right: 2em; + margin-bottom: 1em; + } + .commitMessage h4 { + font-family: var(--font-family); + font-weight: bold; + margin-bottom: .25em; + } + .commitAndRelated { + align-content: flex-start; + display: flex; + flex: 1; + flex-wrap: wrap; + } + gr-file-list { + margin-bottom: 1em; + padding: 0 var(--default-horizontal-margin); + } + @media screen and (max-width: 50em) { + .container { + margin: .5em 0 !important; + } + .container.loading { + margin: 1em var(--default-horizontal-margin) !important; + } + .headerContainer { + height: 5.15em; + } + .header { + align-items: flex-start; + flex-direction: column; + padding: .5em var(--default-horizontal-margin) !important; + } + gr-change-star { + vertical-align: middle; + } + .header-title, + .header-actions, + .header.pinned { + width: 100% !important; + } + .header-title { + font-size: 1.1em; + } + .header-actions { + align-items: center; + display: flex; + justify-content: space-between; + margin-top: .5em; + } + gr-reply-dialog { + min-width: initial; + width: 90vw; + } + .download { + display: none; + } + .patchSelectLabel { + margin-left: 0 !important; + margin-right: .5em; + } + .header select { + margin-left: 0 !important; + margin-right: .5em; + } + .header .reply { + margin-left: 0 !important; + margin-right: .5em; + } + .changeInfo-column:not(:last-of-type) { + margin-right: 0; + padding-right: 0; + } + .changeInfo, + .commitAndRelated { + flex-direction: column; + flex-wrap: nowrap; + } + .changeMetadata { + font-size: 1em; + border-right: none; + margin-bottom: 1em; + margin-top: .25em; + max-width: none; + } + .commitMessage { + flex: initial; + margin-right: 0; + } + } + </style> + <gr-ajax id="detailXHR" + url="[[_computeDetailPath(_changeNum)]]" + params="[[_computeDetailQueryParams()]]" + last-response="{{_change}}" + loading="{{_loading}}"></gr-ajax> + <gr-ajax id="commentsXHR" + url="[[_computeCommentsPath(_changeNum)]]" + last-response="{{_comments}}"></gr-ajax> + <gr-ajax id="commitInfoXHR" + url="[[_computeCommitInfoPath(_changeNum, _patchNum)]]" + last-response="{{_commitInfo}}"></gr-ajax> + <!-- TODO(andybons): Cache the project config. --> + <gr-ajax id="configXHR" + auto + url="[[_computeProjectConfigPath(_change.project)]]" + last-response="{{_projectConfig}}"></gr-ajax> + <div class="container loading" hidden$="{{!_loading}}">Loading...</div> + <div class="container" hidden$="{{_loading}}"> + <div class="headerContainer"> + <div class="header"> + <span class="header-title"> + <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star> + <a href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><span>:</span> + <span>[[_change.subject]]</span> + <span class="changeStatus">[[_computeChangeStatus(_change, _patchNum)]]</span> + </span> + <span class="header-actions"> + <gr-button class="reply" hidden$="[[!_loggedIn]]" hidden on-tap="_handleReplyTap">Reply</gr-button> + <gr-button link class="download" on-tap="_handleDownloadTap">Download</gr-button> + <span> + <label class="patchSelectLabel" for="patchSetSelect">Patch set</label> + <select id="patchSetSelect" on-change="_handlePatchChange"> + <template is="dom-repeat" items="{{_allPatchSets}}" as="patchNumber"> + <option value$="[[patchNumber]]" selected$="[[_computePatchIndexIsSelected(index, _patchNum)]]"> + <span>[[patchNumber]]</span> + / + <span>[[_computeLatestPatchNum(_change)]]</span> + </option> + </template> + </select> + </span> + </span> + </div> + </div> + <section class="changeInfo"> + <div class="changeInfo-column changeMetadata"> + <gr-change-metadata + change="[[_change]]" + mutable="[[_loggedIn]]"></gr-change-metadata> + <gr-change-actions id="actions" + actions="[[_change.actions]]" + change-num="[[_changeNum]]" + patch-num="[[_patchNum]]" + on-reload-change="_handleReloadChange"></gr-change-actions> + </div> + <div class="changeInfo-column commitAndRelated"> + <div class="commitMessage"> + <h4>Commit message</h4> + <gr-linked-text pre + content="[[_commitInfo.message]]" + config="[[_projectConfig.commentlinks]]"></gr-linked-text> + </div> + <div class="relatedChanges"> + <gr-related-changes-list id="relatedChanges" + change="[[_change]]" + server-config="[[serverConfig]]" + patch-num="[[_patchNum]]"></gr-related-changes-list> + </div> + </div> + </section> + <gr-file-list id="fileList" + change-num="[[_changeNum]]" + patch-num="[[_patchNum]]" + comments="[[_comments]]" + selected-index="{{viewState.selectedFileIndex}}"></gr-file-list> + <gr-messages-list id="messageList" + change-num="[[_changeNum]]" + messages="[[_change.messages]]" + comments="[[_comments]]" + project-config="[[_projectConfig]]" + show-reply-buttons="[[_loggedIn]]" + on-reply="_handleMessageReply"></gr-messages-list> + </div> + <gr-overlay id="downloadOverlay" with-backdrop> + <gr-download-dialog + change="[[_change]]" + patch-num="[[_patchNum]]" + config="[[serverConfig.download]]" + on-close="_handleDownloadDialogClose"></gr-download-dialog> + </gr-overlay> + <gr-overlay id="replyOverlay" + on-iron-overlay-opened="_handleReplyOverlayOpen" + with-backdrop> + <gr-reply-dialog id="replyDialog" + change-num="[[_changeNum]]" + patch-num="[[_patchNum]]" + labels="[[_change.labels]]" + permitted-labels="[[_change.permitted_labels]]" + on-send="_handleReplySent" + on-cancel="_handleReplyCancel" + hidden$="[[!_loggedIn]]">Reply</gr-reply-dialog> + </gr-overlay> + </template> + <script src="gr-change-view.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js new file mode 100644 index 0000000..a42a379 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -0,0 +1,354 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-change-view', + + /** + * Fired when the title of the page should change. + * + * @event title-change + */ + + properties: { + /** + * URL params passed from the router. + */ + params: { + type: Object, + observer: '_paramsChanged', + }, + viewState: { + type: Object, + notify: true, + value: function() { return {}; }, + }, + serverConfig: Object, + keyEventTarget: { + type: Object, + value: function() { return document.body; }, + }, + + _comments: Object, + _change: { + type: Object, + observer: '_changeChanged', + }, + _commitInfo: Object, + _changeNum: String, + _patchNum: String, + _allPatchSets: { + type: Array, + computed: '_computeAllPatchSets(_change)', + }, + _loggedIn: { + type: Boolean, + value: false, + }, + _loading: Boolean, + _headerContainerEl: Object, + _headerEl: Object, + _projectConfig: Object, + _boundScrollHandler: { + type: Function, + value: function() { return this._handleBodyScroll.bind(this); }, + }, + }, + + behaviors: [ + Gerrit.KeyboardShortcutBehavior, + Gerrit.RESTClientBehavior, + ], + + ready: function() { + app.accountReady.then(function() { + this._loggedIn = app.loggedIn; + }.bind(this)); + this._headerEl = this.$$('.header'); + }, + + attached: function() { + window.addEventListener('scroll', this._boundScrollHandler); + }, + + detached: function() { + window.removeEventListener('scroll', this._boundScrollHandler); + }, + + _handleBodyScroll: function(e) { + var containerEl = this._headerContainerEl || + this.$$('.headerContainer'); + + // Calculate where the header is relative to the window. + var top = containerEl.offsetTop; + for (var offsetParent = containerEl.offsetParent; + offsetParent; + offsetParent = offsetParent.offsetParent) { + top += offsetParent.offsetTop; + } + // The element may not be displayed yet, in which case do nothing. + if (top == 0) { return; } + + this._headerEl.classList.toggle('pinned', window.scrollY >= top); + }, + + _resetHeaderEl: function() { + var el = this._headerEl || this.$$('.header'); + this._headerEl = el; + el.classList.remove('pinned'); + }, + + _handlePatchChange: function(e) { + var patchNum = e.target.value; + var currentPatchNum = + this._change.revisions[this._change.current_revision]._number; + if (patchNum == currentPatchNum) { + page.show(this._computeChangePath(this._changeNum)); + return; + } + page.show(this._computeChangePath(this._changeNum) + '/' + patchNum); + }, + + _handleReplyTap: function(e) { + e.preventDefault(); + this.$.replyOverlay.open(); + }, + + _handleDownloadTap: function(e) { + e.preventDefault(); + this.$.downloadOverlay.open(); + }, + + _handleDownloadDialogClose: function(e) { + this.$.downloadOverlay.close(); + }, + + _handleMessageReply: function(e) { + var msg = e.detail.message.message; + var quoteStr = msg.split('\n').map( + function(line) { return '> ' + line; }).join('\n') + '\n\n'; + this.$.replyDialog.draft += quoteStr; + this.$.replyOverlay.open(); + }, + + _handleReplyOverlayOpen: function(e) { + this.$.replyDialog.reload().then(function() { + this.async(function() { this.$.replyOverlay.center() }, 1); + }.bind(this)); + this.$.replyDialog.focus(); + }, + + _handleReplySent: function(e) { + this.$.replyOverlay.close(); + this._reload(); + }, + + _handleReplyCancel: function(e) { + this.$.replyOverlay.close(); + }, + + _paramsChanged: function(value) { + if (value.view != this.tagName.toLowerCase()) { return; } + + this._changeNum = value.changeNum; + this._patchNum = value.patchNum; + if (this.viewState.changeNum != this._changeNum || + this.viewState.patchNum != this._patchNum) { + this.set('viewState.selectedFileIndex', 0); + this.set('viewState.changeNum', this._changeNum); + this.set('viewState.patchNum', this._patchNum); + } + if (!this._changeNum) { + return; + } + this._reload().then(function() { + this.$.messageList.topMargin = this._headerEl.offsetHeight; + + // Allow the message list to render before scrolling. + this.async(function() { + var msgPrefix = '#message-'; + var hash = window.location.hash; + if (hash.indexOf(msgPrefix) == 0) { + this.$.messageList.scrollToMessage(hash.substr(msgPrefix.length)); + } + }.bind(this), 1); + + app.accountReady.then(function() { + if (!this._loggedIn) { return; } + + if (this.viewState.showReplyDialog) { + this.$.replyOverlay.open(); + this.set('viewState.showReplyDialog', false); + } + }.bind(this)); + }.bind(this)); + }, + + _changeChanged: function(change) { + if (!change) { return; } + this._patchNum = this._patchNum || + change.revisions[change.current_revision]._number; + + var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')'; + this.fire('title-change', {title: title}); + }, + + _computeChangePath: function(changeNum) { + return '/c/' + changeNum; + }, + + _computeChangePermalink: function(changeNum) { + return '/' + changeNum; + }, + + _computeChangeStatus: function(change, patchNum) { + var status = change.status; + if (status == this.ChangeStatus.NEW) { + var rev = this._getRevisionNumber(change, patchNum); + // TODO(davido): Figure out, why sometimes revision is not there + if (rev == undefined || !rev.draft) { return ''; } + status = this.ChangeStatus.DRAFT; + } + return '(' + status.toLowerCase() + ')'; + }, + + _computeDetailPath: function(changeNum) { + return '/changes/' + changeNum + '/detail'; + }, + + _computeCommitInfoPath: function(changeNum, patchNum) { + return this.changeBaseURL(changeNum, patchNum) + '/commit?links'; + }, + + _computeCommentsPath: function(changeNum) { + return '/changes/' + changeNum + '/comments'; + }, + + _computeProjectConfigPath: function(project) { + return '/projects/' + encodeURIComponent(project) + '/config'; + }, + + _computeDetailQueryParams: function() { + var options = this.listChangesOptionsToHex( + this.ListChangesOption.ALL_REVISIONS, + this.ListChangesOption.CHANGE_ACTIONS, + this.ListChangesOption.DOWNLOAD_COMMANDS + ); + return {O: options}; + }, + + _computeLatestPatchNum: function(change) { + return change.revisions[change.current_revision]._number; + }, + + _computeAllPatchSets: function(change) { + var patchNums = []; + for (var rev in change.revisions) { + patchNums.push(change.revisions[rev]._number); + } + return patchNums.sort(function(a, b) { + return a - b; + }); + }, + + _getRevisionNumber: function(change, patchNum) { + for (var rev in change.revisions) { + if (change.revisions[rev]._number == patchNum) { + return change.revisions[rev]; + } + } + }, + + _computePatchIndexIsSelected: function(index, patchNum) { + return this._allPatchSets[index] == patchNum; + }, + + _computeLabelNames: function(labels) { + return Object.keys(labels).sort(); + }, + + _computeLabelValues: function(labelName, labels) { + var result = []; + var t = labels[labelName]; + if (!t) { return result; } + var approvals = t.all || []; + approvals.forEach(function(label) { + if (label.value && label.value != labels[labelName].default_value) { + var labelClassName; + var labelValPrefix = ''; + if (label.value > 0) { + labelValPrefix = '+'; + labelClassName = 'approved'; + } else if (label.value < 0) { + labelClassName = 'notApproved'; + } + result.push({ + value: labelValPrefix + label.value, + className: labelClassName, + account: label, + }); + } + }); + return result; + }, + + _handleKey: function(e) { + if (this.shouldSupressKeyboardShortcut(e)) { return; } + + switch (e.keyCode) { + case 65: // 'a' + e.preventDefault(); + this.$.replyOverlay.open(); + break; + case 85: // 'u' + e.preventDefault(); + page.show('/'); + break; + } + }, + + _handleReloadChange: function() { + page.show(this._computeChangePath(this._changeNum)); + }, + + _reload: function() { + var detailCompletes = this.$.detailXHR.generateRequest().completes; + this.$.commentsXHR.generateRequest(); + var reloadPatchNumDependentResources = function() { + return Promise.all([ + this.$.commitInfoXHR.generateRequest().completes, + this.$.actions.reload(), + this.$.fileList.reload(), + ]); + }.bind(this); + var reloadDetailDependentResources = function() { + return this.$.relatedChanges.reload(); + }.bind(this); + + this._resetHeaderEl(); + + if (this._patchNum) { + return reloadPatchNumDependentResources().then(function() { + return detailCompletes; + }).then(reloadDetailDependentResources); + } else { + // The patch number is reliant on the change detail request. + return detailCompletes.then(reloadPatchNumDependentResources).then( + reloadDetailDependentResources); + } + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html new file mode 100644 index 0000000..ed9d28d --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -0,0 +1,179 @@ +<!DOCTYPE html> +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-change-view</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../bower_components/page/page.js"></script> +<script src="../../../scripts/fake-app.js"></script> +<script src="../../../scripts/util.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-change-view.html"> + +<test-fixture id="basic"> + <template> + <gr-change-view></gr-change-view> + </template> +</test-fixture> + +<script> + suite('gr-change-view tests', function() { + var element; + var server; + + setup(function() { + element = fixture('basic'); + element.$.configXHR.auto = false; + + server = sinon.fakeServer.create(); + // Eat any requests made by elements in this suite. + server.respondWith( + 'GET', + /\/changes\/(.*)/, + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n{}', + ] + ); + }); + + teardown(function() { + server.restore(); + }); + + test('keyboard shortcuts', function() { + var showStub = sinon.stub(page, 'show'); + + MockInteractions.pressAndReleaseKeyOn(element, 85); // 'u' + assert(showStub.lastCall.calledWithExactly('/'), + 'Should navigate to /'); + showStub.restore(); + + MockInteractions.pressAndReleaseKeyOn(element, 65); // 'a' + var overlayEl = element.$.replyOverlay; + assert.isTrue(overlayEl.opened); + overlayEl.close(); + assert.isFalse(overlayEl.opened); + }); + + test('patch num change', function(done) { + element._changeNum = '42'; + element._patchNum = 2; + element._change = { + change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca', + revisions: { + rev2: {_number: 2}, + rev1: {_number: 1}, + rev13: {_number: 13}, + rev3: {_number: 3}, + }, + current_revision: 'rev3', + status: 'NEW', + labels: {}, + }; + flushAsynchronousOperations(); + var selectEl = element.$$('.header select'); + assert.ok(selectEl); + var optionEls = + Polymer.dom(element.root).querySelectorAll('.header option'); + assert.equal(optionEls.length, 4); + assert.isFalse( + element.$$('.header option[value="1"]').hasAttribute('selected')); + assert.isTrue( + element.$$('.header option[value="2"]').hasAttribute('selected')); + assert.isFalse( + element.$$('.header option[value="3"]').hasAttribute('selected')); + assert.equal(optionEls[3].value, 13); + + var showStub = sinon.stub(page, 'show'); + + var numEvents = 0; + selectEl.addEventListener('change', function(e) { + numEvents++; + if (numEvents == 1) { + assert(showStub.lastCall.calledWithExactly('/c/42/1'), + 'Should navigate to /c/42/1'); + selectEl.value = '3'; + element.fire('change', {}, {node: selectEl}); + } else if (numEvents == 2) { + assert(showStub.lastCall.calledWithExactly('/c/42'), + 'Should navigate to /c/42'); + showStub.restore(); + done(); + } + }); + selectEl.value = '1'; + element.fire('change', {}, {node: selectEl}); + }); + + test('change status new', function() { + element._changeNum = '1'; + element._patchNum = 1; + element._change = { + change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca', + revisions: { + rev1: {_number: 1}, + }, + current_revision: 'rev1', + status: 'NEW', + labels: {}, + }; + var status = element._computeChangeStatus(element._change, '1'); + assert.equal(status, ''); + }); + + test('change status draft', function() { + element._changeNum = '1'; + element._patchNum = 1; + element._change = { + change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca', + revisions: { + rev1: {_number: 1}, + }, + current_revision: 'rev1', + status: 'DRAFT', + labels: {}, + }; + var status = element._computeChangeStatus(element._change, '1'); + assert.equal(status, '(draft)'); + }); + + test('revision status draft', function() { + element._changeNum = '1'; + element._patchNum = 2; + element._change = { + change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca', + revisions: { + rev1: {_number: 1}, + rev2: { + _number: 2, + draft: true, + }, + }, + current_revision: 'rev1', + status: 'NEW', + labels: {}, + }; + var status = element._computeChangeStatus(element._change, '2'); + assert.equal(status, '(draft)'); + }); + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html new file mode 100644 index 0000000..263fb28 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -0,0 +1,68 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> + +<dom-module id="gr-comment-list"> + <template> + <style> + :host { + display: block; + } + .file { + border-top: 1px solid #ddd; + font-weight: bold; + margin: 10px 0 3px; + padding: 10px 0 5px; + } + .container { + display: flex; + margin: 5px 0; + } + .lineNum { + margin-right: .35em; + min-width: 7em; + } + .message { + flex: 1; + white-space: pre-wrap; + word-wrap: break-word; + } + </style> + <template is="dom-repeat" items="{{_files}}" as="file"> + <div class="file"> + <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">[[file]]</a>: + </div> + <template is="dom-repeat" + items="[[_computeCommentsForFile(file)]]" as="comment"> + <div class="container"> + <a class="lineNum" + href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]"> + <span hidden$="[[!comment.line]]"> + <span>[[_computePatchDisplayName(comment)]]</span> + Line <span>[[comment.line]]</span>: + </span> + <span hidden$="[[comment.line]]"> + File comment: + </span> + </a> + <div class="message">[[comment.message]]</div> + </div> + </template> + </template> + </template> + <script src="gr-comment-list.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js new file mode 100644 index 0000000..b40c18e --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -0,0 +1,62 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-comment-list', + + properties: { + changeNum: Number, + comments: { + type: Object, + observer: '_commentsChanged', + }, + patchNum: Number, + + _files: Array, + }, + + _commentsChanged: function(value) { + this._files = Object.keys(value || {}).sort(); + }, + + _computeFileDiffURL: function(file, changeNum, patchNum) { + return '/c/' + changeNum + '/' + patchNum + '/' + file; + }, + + _computeDiffLineURL: function(file, changeNum, patchNum, comment) { + var diffURL = this._computeFileDiffURL(file, changeNum, patchNum); + if (comment.line) { + // TODO(andybons): This is not correct if the comment is on the base. + diffURL += '#' + comment.line; + } + return diffURL; + }, + + _computeCommentsForFile: function(file) { + return this.comments[file]; + }, + + _computePatchDisplayName: function(comment) { + if (comment.side == 'PARENT') { + return 'Base, '; + } + if (comment.patch_set != this.patchNum) { + return 'PS' + comment.patch_set + ', '; + } + return ''; + } + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html new file mode 100644 index 0000000..3896ffa --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -0,0 +1,75 @@ +<!-- +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. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html"> + +<dom-module id="gr-confirm-rebase-dialog"> + <template> + <style> + :host { + display: block; + width: 30em; + } + :host([disabled]) { + opacity: .5; + pointer-events: none; + } + label { + cursor: pointer; + } + .parentRevisionContainer label, + .parentRevisionContainer input[type="text"] { + display: block; + font: inherit; + width: 100%; + } + .parentRevisionContainer label { + margin-bottom: .2em; + } + .clearParentContainer { + margin: .5em 0; + } + </style> + <gr-confirm-dialog + confirm-label="Rebase" + on-confirm="_handleConfirmTap" + on-cancel="_handleCancelTap"> + <div class="header">Confirm rebase</div> + <div class="main"> + <div class="parentRevisionContainer"> + <label for="parentInput"> + Parent revision (optional) + </label> + <input is="iron-input" + type="text" + id="parentInput" + bind-value="{{base}}" + placeholder="Change number"> + </div> + <div class="clearParentContainer"> + <input id="clearParent" + type="checkbox" + on-tap="_handleClearParentTap"> + <label for="clearParent"> + Rebase on top of current branch (clear parent revision). + </label> + </div> + </div> + </gr-confirm-dialog> + </template> + <script src="gr-confirm-rebase-dialog.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js new file mode 100644 index 0000000..42f2167 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -0,0 +1,56 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-confirm-rebase-dialog', + + /** + * Fired when the confirm button is pressed. + * + * @event confirm + */ + + /** + * Fired when the cancel button is pressed. + * + * @event cancel + */ + + properties: { + base: String, + clearParent: Boolean, + }, + + _handleConfirmTap: function(e) { + e.preventDefault(); + this.fire('confirm', null, {bubbles: false}); + }, + + _handleCancelTap: function(e) { + e.preventDefault(); + this.fire('cancel', null, {bubbles: false}); + }, + + _handleClearParentTap: function(e) { + var clear = Polymer.dom(e).rootTarget.checked; + if (clear) { + this.base = ''; + } + this.$.parentInput.disabled = clear; + this.clearParent = clear; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html new file mode 100644 index 0000000..c02e11e --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -0,0 +1,51 @@ +<!DOCTYPE html> +<!-- +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. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-confirm-rebase-dialog</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-confirm-rebase-dialog.html"> + +<test-fixture id="basic"> + <template> + <gr-confirm-rebase-dialog></gr-confirm-rebase-dialog> + </template> +</test-fixture> + +<script> + suite('gr-confirm-rebase-dialog tests', function() { + var element; + + setup(function() { + element = fixture('basic'); + }); + + test('controls', function() { + assert.isFalse(element.$.parentInput.hasAttribute('disabled')); + assert.isFalse(element.$.clearParent.checked); + element.base = 'something great'; + MockInteractions.tap(element.$.clearParent); + assert.isTrue(element.$.parentInput.hasAttribute('disabled')); + assert.isTrue(element.$.clearParent.checked); + assert.equal(element.base, ''); + }); + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html new file mode 100644 index 0000000..77a262d --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -0,0 +1,144 @@ +<!-- +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. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../bower_components/iron-input/iron-input.html"> +<link rel="import" href="../../../behaviors/rest-client-behavior.html"> +<link rel="import" href="../../shared/gr-button/gr-button.html"> + +<dom-module id="gr-download-dialog"> + <template> + <style> + :host { + display: block; + padding: 1em; + } + ul { + list-style: none; + margin-bottom: .5em; + } + li { + display: inline-block; + margin: 0; + padding: 0; + } + li gr-button { + margin-right: 1em; + } + label, + input { + display: block; + } + label { + font-weight: bold; + } + input { + font-family: var(--monospace-font-family); + font-size: inherit; + margin-bottom: .5em; + width: 60em; + } + li[selected] gr-button { + color: #000; + font-weight: bold; + text-decoration: none; + } + header { + display: flex; + justify-content: space-between; + } + main { + border-bottom: 1px solid #ddd; + border-top: 1px solid #ddd; + padding: .5em; + } + footer { + display: flex; + justify-content: space-between; + padding-top: .75em; + } + .closeButtonContainer { + display: flex; + flex: 1; + justify-content: flex-end; + } + .patchFiles { + margin-right: 2em; + } + .patchFiles a, + .archives a { + display: inline-block; + margin-right: 1em; + } + .patchFiles a:last-of-type, + .archives a:last-of-type { + margin-right: 0; + } + </style> + <header> + <ul hidden$="[[!_schemes.length]]" hidden> + <template is="dom-repeat" items="[[_schemes]]" as="scheme"> + <li selected$="[[_computeSchemeSelected(scheme, _selectedScheme)]]"> + <gr-button link data-scheme$="[[scheme]]" on-tap="_handleSchemeTap"> + [[scheme]] + </gr-button> + </li> + </template> + </ul> + <span class="closeButtonContainer"> + <gr-button link on-tap="_handleCloseTap">Close</gr-button> + </span> + </header> + <main hidden$="[[!_schemes.length]]" hidden> + <template is="dom-repeat" + items="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]" + as="command"> + <div class="command"> + <label>[[command.title]]</label> + <input is="iron-input" + type="text" + bind-value="[[command.command]]" + on-tap="_handleInputTap" + readonly> + </div> + </template> + </main> + <footer> + <div class="patchFiles"> + <label>Patch file</label> + <div> + <a href$="[[_computeDownloadLink(change, patchNum)]]"> + [[_computeDownloadFilename(change, patchNum)]] + </a> + <a href$="[[_computeZipDownloadLink(change, patchNum)]]"> + [[_computeZipDownloadFilename(change, patchNum)]] + </a> + </div> + </div> + <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden> + <label>Archive</label> + <div class="archives"> + <template is="dom-repeat" items="[[config.archives]]" as="format"> + <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"> + [[format]] + </a> + </template> + </div> + </div> + </footer> + </template> + <script src="gr-download-dialog.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js new file mode 100644 index 0000000..6677d62 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -0,0 +1,136 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-download-dialog', + + /** + * Fired when the user presses the close button. + * + * @event close + */ + + properties: { + change: Object, + patchNum: String, + config: Object, + + _schemes: { + type: Array, + value: function() { return []; }, + computed: '_computeSchemes(change, patchNum)', + observer: '_schemesChanged', + }, + _selectedScheme: String, + }, + + hostAttributes: { + role: 'dialog', + }, + + behaviors: [ + Gerrit.RESTClientBehavior, + ], + + _computeDownloadCommands: function(change, patchNum, _selectedScheme) { + var commandObj; + for (var rev in change.revisions) { + if (change.revisions[rev]._number == patchNum) { + commandObj = change.revisions[rev].fetch[_selectedScheme].commands; + break; + } + } + var commands = []; + for (var title in commandObj) { + commands.push({ + title: title, + command: commandObj[title], + }); + } + return commands; + }, + + _computeZipDownloadLink: function(change, patchNum) { + return this._computeDownloadLink(change, patchNum, true); + }, + + _computeZipDownloadFilename: function(change, patchNum) { + return this._computeDownloadFilename(change, patchNum, true); + }, + + _computeDownloadLink: function(change, patchNum, zip) { + return this.changeBaseURL(change._number, patchNum) + '/patch?' + + (zip ? 'zip' : 'download'); + }, + + _computeDownloadFilename: function(change, patchNum, zip) { + var shortRev; + for (var rev in change.revisions) { + if (change.revisions[rev]._number == patchNum) { + shortRev = rev.substr(0, 7); + break; + } + } + return shortRev + '.diff.' + (zip ? 'zip' : 'base64'); + }, + + _computeArchiveDownloadLink: function(change, patchNum, format) { + return this.changeBaseURL(change._number, patchNum) + + '/archive?format=' + format; + }, + + _computeSchemes: function(change, patchNum) { + for (var rev in change.revisions) { + if (change.revisions[rev]._number == patchNum) { + var fetch = change.revisions[rev].fetch; + if (fetch) { + return Object.keys(fetch).sort(); + } + break; + } + } + return []; + }, + + _computeSchemeSelected: function(scheme, selectedScheme) { + return scheme == selectedScheme; + }, + + _handleSchemeTap: function(e) { + e.preventDefault(); + var el = Polymer.dom(e).rootTarget; + // TODO(andybons): Save as default scheme in preferences. + this._selectedScheme = el.getAttribute('data-scheme'); + }, + + _handleInputTap: function(e) { + e.preventDefault(); + Polymer.dom(e).rootTarget.select(); + }, + + _handleCloseTap: function(e) { + e.preventDefault(); + this.fire('close', null, {bubbles: false}); + }, + + _schemesChanged: function(schemes) { + if (schemes.length == 0) { return; } + if (schemes.indexOf(this._selectedScheme) == -1) { + this._selectedScheme = schemes.sort()[0]; + } + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html new file mode 100644 index 0000000..2480c4a --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -0,0 +1,123 @@ +<!DOCTYPE html> +<!-- +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. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-download-dialog</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-download-dialog.html"> + +<test-fixture id="basic"> + <template> + <gr-download-dialog></gr-download-dialog> + </template> +</test-fixture> + +<script> + suite('gr-download-dialog tests', function() { + var element; + + setup(function() { + element = fixture('basic'); + element.change = { + current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72', + revisions: { + '34685798fe548b6d17d1e8e5edc43a26d055cc72': { + _number: 1, + fetch: { + repo: { + commands: { + repo: 'repo download test-project 5/1' + } + }, + ssh: { + commands: { + 'Checkout': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git checkout FETCH_HEAD', + 'Cherry Pick': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git cherry-pick FETCH_HEAD', + 'Format Patch': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD', + 'Pull': 'git pull ssh://andybons@localhost:29418/test-project refs/changes/05/5/1' + } + }, + http: { + commands: { + 'Checkout': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git checkout FETCH_HEAD', + 'Cherry Pick': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git cherry-pick FETCH_HEAD', + 'Format Patch': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD', + 'Pull': 'git pull http://andybons@localhost:8080/a/test-project refs/changes/05/5/1' + } + } + } + } + } + }; + element.patchNum = 1; + element.config = { + schemes: { + 'anonymous http': {}, + http: {}, + repo: {}, + ssh: {}, + }, + archives: ['tgz', 'tar', 'tbz2', 'txz'], + }; + }); + + test('element visibility', function() { + assert.isFalse(element.$$('ul').hasAttribute('hidden')); + assert.isFalse(element.$$('main').hasAttribute('hidden')); + assert.isFalse(element.$$('.archivesContainer').hasAttribute('hidden')); + + element.set('config.archives', []); + assert.isTrue(element.$$('.archivesContainer').hasAttribute('hidden')); + }); + + test('computed fields', function() { + assert.equal(element._computeArchiveDownloadLink( + {_number: 123}, 2, 'tgz'), + '/changes/123/revisions/2/archive?format=tgz'); + }); + + test('close event', function(done) { + element.addEventListener('close', function() { + done(); + }); + MockInteractions.tap(element.$$('.closeButtonContainer gr-button')); + }); + + test('tab selection', function() { + flushAsynchronousOperations(); + var el = element.$$('[data-scheme="http"]').parentElement; + assert.isTrue(el.hasAttribute('selected')); + ['repo', 'ssh'].forEach(function(scheme) { + var el = element.$$('[data-scheme="' + scheme + '"]').parentElement; + assert.isFalse(el.hasAttribute('selected')); + }); + + MockInteractions.tap(element.$$('[data-scheme="ssh"]')); + el = element.$$('[data-scheme="ssh"]').parentElement; + assert.isTrue(el.hasAttribute('selected')); + ['http', 'repo'].forEach(function(scheme) { + var el = element.$$('[data-scheme="' + scheme + '"]').parentElement; + assert.isFalse(el.hasAttribute('selected')); + }); + }); + + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html new file mode 100644 index 0000000..e010468 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -0,0 +1,159 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html"> +<link rel="import" href="../../../behaviors/rest-client-behavior.html"> +<link rel="import" href="../../shared/gr-ajax/gr-ajax.html"> +<link rel="import" href="../../shared/gr-request/gr-request.html"> + +<dom-module id="gr-file-list"> + <template> + <style> + :host { + display: block; + } + .row { + display: flex; + padding: .1em .25em; + } + .header { + font-weight: bold; + } + .positionIndicator, + .reviewed, + .status { + align-items: center; + display: inline-flex; + } + .reviewed, + .status { + justify-content: center; + width: 1.5em; + } + .positionIndicator { + justify-content: flex-start; + visibility: hidden; + width: 1.25em; + } + .row[selected] { + background-color: #ebf5fb; + } + .row[selected] .positionIndicator { + visibility: visible; + } + .path { + flex: 1; + overflow: hidden; + padding-left: .35em; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + } + .row:not(.header) .path:hover { + text-decoration: underline; + } + .comments, + .stats { + text-align: right; + } + .comments { + min-width: 10em; + } + .stats { + min-width: 7em; + } + .invisible { + visibility: hidden; + } + .row:not(.header) .stats { + font-family: var(--monospace-font-family); + } + .added { + color: #388E3C; + } + .removed { + color: #D32F2F; + } + .reviewed input[type="checkbox"] { + display: inline-block; + } + .drafts { + color: #C62828; + font-weight: bold; + } + @media screen and (max-width: 50em) { + .row[selected] { + background-color: transparent; + } + .positionIndicator, + .stats { + display: none; + } + .reviewed, + .status { + justify-content: flex-start; + } + .comments { + min-width: initial; + } + } + </style> + <gr-ajax id="filesXHR" + url="[[_computeFilesURL(changeNum, patchNum)]]" + on-response="_handleResponse"></gr-ajax> + <gr-ajax id="draftsXHR" + url="[[_computeDraftsURL(changeNum, patchNum)]]" + last-response="{{_drafts}}"></gr-ajax> + <gr-ajax id="reviewedXHR" + url="[[_computeReviewedURL(changeNum, patchNum)]]" + last-response="{{_reviewed}}"></gr-ajax> + </gr-ajax> + + <div class="row header"> + <div class="positionIndicator"></div> + <div class="reviewed" hidden$="[[!_loggedIn]]" hidden></div> + <div class="status"></div> + <div class="path">Path</div> + <div class="comments">Comments</div> + <div class="stats">Stats</div> + </div> + <template is="dom-repeat" items="{{files}}" as="file"> + <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]"> + <div class="positionIndicator">▶</div> + <div class="reviewed" hidden$="[[!_loggedIn]]" hidden> + <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]" + data-path$="[[file.__path]]" on-change="_handleReviewedChange"> + </div> + <div class$="[[_computeClass('status', file.__path)]]"> + [[_computeFileStatus(file.status)]] + </div> + <a class="path" href$="[[_computeDiffURL(changeNum, patchNum, file.__path)]]"> + [[_computeFileDisplayName(file.__path)]] + </a> + <div class="comments"> + <span class="drafts">[[_computeDraftsString(_drafts, file.__path)]]</span> + [[_computeCommentsString(comments, patchNum, file.__path)]] + </div> + <div class$="[[_computeClass('stats', file.__path)]]"> + <span class="added">+[[file.lines_inserted]]</span> + <span class="removed">-[[file.lines_deleted]]</span> + </div> + </div> + </template> + </template> + <script src="gr-file-list.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js new file mode 100644 index 0000000..9fe5ca1 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -0,0 +1,205 @@ +// 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. +(function() { + 'use strict'; + + var COMMIT_MESSAGE_PATH = '/COMMIT_MSG'; + + Polymer({ + is: 'gr-file-list', + + properties: { + patchNum: String, + changeNum: String, + comments: Object, + files: Array, + selectedIndex: { + type: Number, + notify: true, + }, + keyEventTarget: { + type: Object, + value: function() { return document.body; }, + }, + + _loggedIn: { + type: Boolean, + value: false, + }, + _drafts: Object, + _reviewed: { + type: Array, + value: function() { return []; }, + }, + _xhrPromise: Object, // Used for testing. + }, + + behaviors: [ + Gerrit.KeyboardShortcutBehavior, + Gerrit.RESTClientBehavior, + ], + + reload: function() { + if (!this.changeNum || !this.patchNum) { + return Promise.resolve(); + } + return Promise.all([ + this.$.filesXHR.generateRequest().completes, + app.accountReady.then(function() { + this._loggedIn = app.loggedIn; + if (!app.loggedIn) { return; } + this.$.draftsXHR.generateRequest(); + this.$.reviewedXHR.generateRequest(); + }.bind(this)), + ]); + }, + + _computeFilesURL: function(changeNum, patchNum) { + return this.changeBaseURL(changeNum, patchNum) + '/files'; + }, + + _computeCommentsString: function(comments, patchNum, path) { + var patchComments = (comments[path] || []).filter(function(c) { + return c.patch_set == patchNum; + }); + var num = patchComments.length; + if (num == 0) { return ''; } + if (num == 1) { return '1 comment'; } + if (num > 1) { return num + ' comments'; } + }, + + _computeReviewedURL: function(changeNum, patchNum) { + return this.changeBaseURL(changeNum, patchNum) + '/files?reviewed'; + }, + + _computeReviewed: function(file, _reviewed) { + return _reviewed.indexOf(file.__path) != -1; + }, + + _handleReviewedChange: function(e) { + var path = Polymer.dom(e).rootTarget.getAttribute('data-path'); + var index = this._reviewed.indexOf(path); + var reviewed = index != -1; + if (reviewed) { + this.splice('_reviewed', index, 1); + } else { + this.push('_reviewed', path); + } + + var method = reviewed ? 'DELETE' : 'PUT'; + var url = this.changeBaseURL(this.changeNum, this.patchNum) + + '/files/' + encodeURIComponent(path) + '/reviewed'; + this._send(method, url).catch(function(err) { + alert('Couldn’t change file review status. Check the console ' + + 'and contact the PolyGerrit team for assistance.'); + throw err; + }.bind(this)); + }, + + _computeDraftsURL: function(changeNum, patchNum) { + return this.changeBaseURL(changeNum, patchNum) + '/drafts'; + }, + + _computeDraftsString: function(drafts, path) { + var num = (drafts[path] || []).length; + if (num == 0) { return ''; } + if (num == 1) { return '1 draft'; } + if (num > 1) { return num + ' drafts'; } + }, + + _handleResponse: function(e, req) { + var result = e.detail.response; + var paths = Object.keys(result).sort(); + var files = []; + for (var i = 0; i < paths.length; i++) { + var info = result[paths[i]]; + info.__path = paths[i]; + info.lines_inserted = info.lines_inserted || 0; + info.lines_deleted = info.lines_deleted || 0; + files.push(info); + } + this.files = files; + }, + + _handleKey: function(e) { + if (this.shouldSupressKeyboardShortcut(e)) { return; } + + switch (e.keyCode) { + case 74: // 'j' + e.preventDefault(); + this.selectedIndex = + Math.min(this.files.length - 1, this.selectedIndex + 1); + break; + case 75: // 'k' + e.preventDefault(); + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + break; + case 219: // '[' + e.preventDefault(); + this._openSelectedFile(this.files.length - 1); + break; + case 221: // ']' + e.preventDefault(); + this._openSelectedFile(0); + break; + case 13: // <enter> + case 79: // 'o' + e.preventDefault(); + this._openSelectedFile(); + break; + } + }, + + _openSelectedFile: function(opt_index) { + if (opt_index != null) { + this.selectedIndex = opt_index; + } + page.show(this._computeDiffURL(this.changeNum, this.patchNum, + this.files[this.selectedIndex].__path)); + }, + + _computeFileSelected: function(index, selectedIndex) { + return index == selectedIndex; + }, + + _computeFileStatus: function(status) { + return status || 'M'; + }, + + _computeDiffURL: function(changeNum, patchNum, path) { + return '/c/' + changeNum + '/' + patchNum + '/' + path; + }, + + _computeFileDisplayName: function(path) { + return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path; + }, + + _computeClass: function(baseClass, path) { + var classes = [baseClass]; + if (path == COMMIT_MESSAGE_PATH) { + classes.push('invisible'); + } + return classes.join(' '); + }, + + _send: function(method, url) { + var xhr = document.createElement('gr-request'); + this._xhrPromise = xhr.send({ + method: method, + url: url, + }); + return this._xhrPromise; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html new file mode 100644 index 0000000..06a01c6 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -0,0 +1,320 @@ +<!DOCTYPE html> +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-file-list</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../bower_components/page/page.js"></script> +<script src="../../../scripts/fake-app.js"></script> +<script src="../../../scripts/util.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-file-list.html"> + +<test-fixture id="basic"> + <template> + <gr-file-list></gr-file-list> + </template> +</test-fixture> + +<script> + suite('gr-file-list tests', function() { + var element; + var server; + + setup(function() { + element = fixture('basic'); + server = sinon.fakeServer.create(); + server.respondWith( + 'GET', + '/changes/42/revisions/1/files', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + JSON.stringify({ + '/COMMIT_MSG': { + status: 'A', + lines_inserted: 9, + size_delta: 317, + size: 317 + }, + 'myfile.txt': { + lines_inserted: 35, + size_delta: 1146, + size: 1167 + } + }), + ] + ); + server.respondWith( + 'GET', + '/changes/42/revisions/2/files', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + JSON.stringify({ + '/COMMIT_MSG': { + status: 'A', + lines_inserted: 9, + size_delta: 317, + size: 317 + }, + 'myfile.txt': { + lines_inserted: 35, + size_delta: 1146, + size: 1167 + }, + 'file_added_in_rev2.txt': { + lines_inserted: 98, + size_delta: 234, + size: 136 + } + }), + ] + ); + server.respondWith( + 'GET', + '/changes/42/revisions/1/drafts', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + '{}', + ] + ); + server.respondWith( + 'GET', + '/changes/42/revisions/2/drafts', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + '{}', + ] + ); + server.respondWith( + 'GET', + '/changes/42/revisions/1/files?reviewed', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + '["/COMMIT_MSG"]', + ] + ); + server.respondWith( + 'GET', + '/changes/42/revisions/2/files?reviewed', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + '["/COMMIT_MSG","myfile.txt"]', + ] + ); + server.respondWith( + 'PUT', + '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed', + [ + 201, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + '""', + ] + ); + server.respondWith( + 'DELETE', + '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed', + [ + 204, + {'Content-Type': 'application/json'}, + '', + ] + ); + + app.loggedIn = true; + }); + + teardown(function() { + server.restore(); + }); + + test('requests', function(done) { + element.changeNum = '42'; + element.patchNum = '1'; + element.reload(); + server.respond(); + + element.async(function() { + var filenames = element.files.map(function(f) { + return f.__path; + }); + assert.deepEqual(filenames, ['/COMMIT_MSG', 'myfile.txt']); + assert.deepEqual(element._reviewed, ['/COMMIT_MSG']); + + element.patchNum = '2'; + element.reload(); + server.respond(); + element.async(function() { + filenames = element.files.map(function(f) { + return f.__path; + }); + assert.deepEqual(filenames, + ['/COMMIT_MSG', 'file_added_in_rev2.txt', 'myfile.txt']); + assert.deepEqual(element._reviewed, ['/COMMIT_MSG', 'myfile.txt']); + done(); + }, 1); + }, 1); + }); + + test('keyboard shortcuts', function(done) { + element.changeNum = '42'; + element.patchNum = '2'; + element.selectedIndex = 0; + element.reload(); + server.respond(); + + element.async(function() { + var elementItems = Polymer.dom(element.root).querySelectorAll( + '.row:not(.header)'); + assert.equal(elementItems.length, 3); + assert.isTrue(elementItems[0].hasAttribute('selected')); + assert.isFalse(elementItems[1].hasAttribute('selected')); + assert.isFalse(elementItems[2].hasAttribute('selected')); + MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j' + assert.equal(element.selectedIndex, 1); + MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j' + + var showStub = sinon.stub(page, 'show'); + assert.equal(element.selectedIndex, 2); + MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter' + assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'), + 'Should navigate to /c/42/2/myfile.txt'); + + MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k' + assert.equal(element.selectedIndex, 1); + MockInteractions.pressAndReleaseKeyOn(element, 79); // 'o' + assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'), + 'Should navigate to /c/42/2/file_added_in_rev2.txt'); + + MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k' + MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k' + MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k' + assert.equal(element.selectedIndex, 0); + + showStub.restore(); + done(); + }, 1); + }); + + test('comment filtering', function() { + var comments = { + '/COMMIT_MSG': [ + {patch_set: 1, message: 'Done'}, + {patch_set: 1, message: 'oh hay'}, + {patch_set: 2, message: 'hello'}, + ], + 'myfile.txt': [ + {patch_set: 1, message: 'good news!'}, + {patch_set: 2, message: 'wat!?'}, + {patch_set: 2, message: 'hi'}, + ], + }; + assert.equal( + element._computeCommentsString(comments, '1', '/COMMIT_MSG'), + '2 comments'); + assert.equal( + element._computeCommentsString(comments, '1', 'myfile.txt'), + '1 comment'); + assert.equal( + element._computeCommentsString(comments, '1', + 'file_added_in_rev2.txt'), + ''); + assert.equal( + element._computeCommentsString(comments, '2', '/COMMIT_MSG'), + '1 comment'); + assert.equal( + element._computeCommentsString(comments, '2', 'myfile.txt'), + '2 comments'); + assert.equal( + element._computeCommentsString(comments, '2', + 'file_added_in_rev2.txt'), + ''); + }); + + test('computed properties', function() { + assert.equal(element._computeFileStatus('A'), 'A'); + assert.equal(element._computeFileStatus(undefined), 'M'); + assert.equal(element._computeFileStatus(null), 'M'); + + assert.equal(element._computeFileDisplayName('/foo/bar/baz'), + '/foo/bar/baz'); + assert.equal(element._computeFileDisplayName('/COMMIT_MSG'), + 'Commit message'); + + assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz'); + assert.equal(element._computeClass('clazz', '/COMMIT_MSG'), + 'clazz invisible'); + }); + + test('file review status', function(done) { + element.changeNum = '42'; + element.patchNum = '2'; + element.reload(); + server.respond(); + + element.async(function() { + var fileRows = + Polymer.dom(element.root).querySelectorAll('.row:not(.header)'); + var commitMsg = fileRows[0].querySelector('input[type="checkbox"]'); + var fileAdded = fileRows[1].querySelector('input[type="checkbox"]'); + var myFile = fileRows[2].querySelector('input[type="checkbox"]'); + + assert.isTrue(commitMsg.checked); + assert.isFalse(fileAdded.checked); + assert.isTrue(myFile.checked); + + assert.equal(element._reviewed.length, 2); + + MockInteractions.tap(commitMsg); + server.respond(); + element._xhrPromise.then(function(req) { + assert.equal(element._reviewed.length, 1); + assert.equal(req.status, 204); + assert.equal(req.url, + '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed'); + + MockInteractions.tap(commitMsg); + server.respond(); + }).then(function() { + element._xhrPromise.then(function(req) { + assert.equal(element._reviewed.length, 2); + assert.equal(req.status, 201); + assert.equal(req.url, + '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed'); + + done(); + }); + }); + }, 1); + }); + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html new file mode 100644 index 0000000..5733acd --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -0,0 +1,125 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../shared/gr-account-link/gr-account-link.html"> +<link rel="import" href="../../shared/gr-button/gr-button.html"> +<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html"> +<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html"> + +<link rel="import" href="../gr-comment-list/gr-comment-list.html"> + +<dom-module id="gr-message"> + <template> + <style> + :host { + border-top: 1px solid #ddd; + display: block; + position: relative; + } + :host(:not([expanded])) { + cursor: pointer; + } + gr-avatar { + position: absolute; + left: var(--default-horizontal-margin); + } + .collapsed .contentContainer { + color: #777; + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; + } + .showAvatar.expanded .contentContainer { + margin-left: calc(var(--default-horizontal-margin) + 2.5em); + padding: 10px 0; + } + .showAvatar.collapsed .contentContainer { + margin-left: calc(var(--default-horizontal-margin) + 1.75em); + padding: 10px 75px 10px 0; + } + .hideAvatar.collapsed .contentContainer, + .hideAvatar.expanded .contentContainer { + margin-left: 0; + padding: 10px 75px 10px 0; + } + .collapsed gr-avatar { + top: 8px; + height: 1.75em; + width: 1.75em; + } + .expanded gr-avatar { + top: 12px; + height: 2.5em; + width: 2.5em; + } + .name { + font-weight: bold; + } + .content { + font-family: var(--monospace-font-family); + } + .collapsed .name, + .collapsed .content, + .collapsed .message { + display: inline; + } + .collapsed gr-comment-list, + .collapsed .replyContainer { + display: none; + } + .collapsed .name { + color: var(--default-text-color); + } + .expanded .name { + cursor: pointer; + } + .date { + color: #666; + position: absolute; + right: var(--default-horizontal-margin); + top: 10px; + } + .replyContainer { + padding: .5em 0 1em; + } + </style> + <div class$="[[_computeClass(expanded, showAvatar)]]"> + <gr-avatar account="[[message.author]]" image-size="100"></gr-avatar> + <div class="contentContainer"> + <div class="name" on-tap="_handleNameTap">[[message.author.name]]</div> + <div class="content"> + <gr-linked-text class="message" + pre="[[expanded]]" + content="[[message.message]]" + disabled="[[!expanded]]" + config="[[projectConfig.commentlinks]]"></gr-linked-text> + <gr-comment-list + comments="[[comments]]" + change-num="[[changeNum]]" + patch-num="[[message._revision_number]]"></gr-comment-list> + </div> + <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap"> + <gr-date-formatter date-str="[[message.date]]"></gr-date-formatter> + </a> + </div> + <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden> + <gr-button small on-tap="_handleReplyTap">Reply</gr-button> + </div> + </div> + </template> + <script src="gr-message.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js new file mode 100644 index 0000000..1ab5e6c --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -0,0 +1,111 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-message', + + /** + * Fired when this message's permalink is tapped. + * + * @event scroll-to + */ + + /** + * Fired when this message's reply link is tapped. + * + * @event reply + */ + + listeners: { + 'tap': '_handleTap', + }, + + properties: { + changeNum: Number, + message: Object, + comments: { + type: Object, + observer: '_commentsChanged', + }, + expanded: { + type: Boolean, + value: true, + reflectToAttribute: true, + }, + showAvatar: { + type: Boolean, + value: false, + }, + showReplyButton: { + type: Boolean, + value: false, + }, + projectConfig: Object, + }, + + ready: function() { + app.configReady.then(function(cfg) { + this.showAvatar = !!(cfg && cfg.plugin && cfg.plugin.has_avatars) && + this.message && this.message.author; + }.bind(this)); + }, + + _commentsChanged: function(value) { + this.expanded = Object.keys(value || {}).length > 0; + }, + + _handleTap: function(e) { + if (this.expanded) { return; } + this.expanded = true; + }, + + _handleNameTap: function(e) { + if (!this.expanded) { return; } + e.stopPropagation(); + this.expanded = false; + }, + + _computeClass: function(expanded, showAvatar) { + var classes = []; + classes.push(expanded ? 'expanded' : 'collapsed'); + classes.push(showAvatar ? 'showAvatar' : 'hideAvatar'); + return classes.join(' '); + }, + + _computeMessageHash: function(message) { + return '#message-' + message.id; + }, + + _handleLinkTap: function(e) { + e.preventDefault(); + + this.fire('scroll-to', {message: this.message}, {bubbles: false}); + + var hash = this._computeMessageHash(this.message); + // Don't add the hash to the window history if it's already there. + // Otherwise you mess up expected back button behavior. + if (window.location.hash == hash) { return; } + // Change the URL but don’t trigger a nav event. Otherwise it will + // reload the page. + page.show(window.location.pathname + hash, null, false); + }, + + _handleReplyTap: function(e) { + e.preventDefault(); + this.fire('reply', {message: this.message}); + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html new file mode 100644 index 0000000..0f09b70 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -0,0 +1,64 @@ +<!DOCTYPE html> +<!-- +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. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-message</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../scripts/fake-app.js"></script> +<script src="../../../scripts/util.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-message.html"> + +<test-fixture id="basic"> + <template> + <gr-message></gr-message> + </template> +</test-fixture> + +<script> + suite('gr-message tests', function() { + var element; + + setup(function() { + element = fixture('basic'); + }); + + test('reply event', function(done) { + element.message = { + 'id': '47c43261_55aa2c41', + 'author': { + '_account_id': 1115495, + 'name': 'Andrew Bonventre', + 'email': 'andybons@chromium.org', + }, + 'date': '2016-01-12 20:24:49.448000000', + 'message': 'Uploaded patch set 1.', + '_revision_number': 1 + }; + + element.addEventListener('reply', function(e) { + assert.deepEqual(e.detail.message, element.message); + done(); + }); + MockInteractions.tap(element.$$('.replyContainer gr-button')); + }); + + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html new file mode 100644 index 0000000..8a66d03 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -0,0 +1,62 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../shared/gr-button/gr-button.html"> +<link rel="import" href="../gr-message/gr-message.html"> + +<dom-module id="gr-messages-list"> + <template> + <style> + :host { + display: block; + } + .header { + display: flex; + justify-content: space-between; + margin-bottom: .35em; + } + .header, + gr-message { + padding: 0 var(--default-horizontal-margin); + } + .highlighted { + animation: 3s fadeOut; + } + @keyframes fadeOut { + 0% { background-color: #fff9c4; } + 100% { background-color: #fff; } + } + </style> + <div class="header"> + <h3>Messages</h3> + <gr-button link on-tap="_handleExpandCollapseTap"> + [[_computeExpandCollapseMessage(_expanded)]] + </gr-button> + </div> + <template is="dom-repeat" items="[[messages]]" as="message"> + <gr-message + change-num="[[changeNum]]" + message="[[message]]" + comments="[[_computeCommentsForMessage(comments, message, index)]]" + project-config="[[projectConfig]]" + show-reply-button="[[showReplyButtons]]" + on-scroll-to="_handleScrollTo" + data-message-id$="[[message.id]]"></gr-message> + </template> + </template> + <script src="gr-messages-list.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js new file mode 100644 index 0000000..1b9ce14 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -0,0 +1,111 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-messages-list', + + properties: { + changeNum: Number, + messages: { + type: Array, + value: function() { return []; }, + }, + comments: Object, + projectConfig: Object, + topMargin: Number, + showReplyButtons: { + type: Boolean, + value: false, + }, + + _expanded: { + type: Boolean, + value: false, + }, + }, + + scrollToMessage: function(messageID) { + var el = this.$$('[data-message-id="' + messageID + '"]'); + if (!el) { return; } + + el.expanded = true; + var top = el.offsetTop; + for (var offsetParent = el.offsetParent; + offsetParent; + offsetParent = offsetParent.offsetParent) { + top += offsetParent.offsetTop; + } + window.scrollTo(0, top - this.topMargin); + this._highlightEl(el); + }, + + _highlightEl: function(el) { + var highlightedEls = + Polymer.dom(this.root).querySelectorAll('.highlighted'); + for (var i = 0; i < highlightedEls.length; i++) { + highlightedEls[i].classList.remove('highlighted'); + } + function handleAnimationEnd() { + el.removeEventListener('animationend', handleAnimationEnd); + el.classList.remove('highlighted'); + } + el.addEventListener('animationend', handleAnimationEnd); + el.classList.add('highlighted'); + }, + + _handleExpandCollapseTap: function(e) { + e.preventDefault(); + this._expanded = !this._expanded; + var messageEls = Polymer.dom(this.root).querySelectorAll('gr-message'); + for (var i = 0; i < messageEls.length; i++) { + messageEls[i].expanded = this._expanded; + } + }, + + _handleScrollTo: function(e) { + this.scrollToMessage(e.detail.message.id); + }, + + _computeExpandCollapseMessage: function(expanded) { + return expanded ? 'Collapse all' : 'Expand all'; + }, + + _computeCommentsForMessage: function(comments, message, index) { + comments = comments || {}; + var messages = this.messages || []; + var msgComments = {}; + var mDate = util.parseDate(message.date); + var nextMDate; + if (index < messages.length - 1) { + nextMDate = util.parseDate(messages[index + 1].date); + } + for (var file in comments) { + var fileComments = comments[file]; + for (var i = 0; i < fileComments.length; i++) { + var cDate = util.parseDate(fileComments[i].updated); + if (cDate >= mDate) { + if (nextMDate && cDate >= nextMDate) { + continue; + } + msgComments[file] = msgComments[file] || []; + msgComments[file].push(fileComments[i]); + } + } + } + return msgComments; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html new file mode 100644 index 0000000..5a562ba --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -0,0 +1,133 @@ +<!DOCTYPE html> +<!-- +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. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-messages-list</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../scripts/fake-app.js"></script> +<script src="../../../scripts/util.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-messages-list.html"> + +<test-fixture id="basic"> + <template> + <gr-messages-list></gr-messages-list> + </template> +</test-fixture> + +<script> + suite('gr-messages-list tests', function() { + var element; + + setup(function() { + element = fixture('basic'); + element.messages = [ + { + 'id': '47c43261_55aa2c41', + 'author': { + '_account_id': 1115495, + 'name': 'Andrew Bonventre', + 'email': 'andybons@chromium.org', + }, + 'date': '2016-01-12 20:24:49.448000000', + 'message': 'Uploaded patch set 1.', + '_revision_number': 1 + }, + { + 'id': '47c43261_9593e420', + 'author': { + '_account_id': 1115495, + 'name': 'Andrew Bonventre', + 'email': 'andybons@chromium.org', + }, + 'date': '2016-01-12 20:28:33.038000000', + 'message': 'Patch Set 1:\n\n(1 comment)', + '_revision_number': 1 + }, + { + 'id': '87b2aaf4_f73260c5', + 'author': { + '_account_id': 1143760, + 'name': 'Mark Mentovai', + 'email': 'mark@chromium.org', + }, + 'date': '2016-01-12 21:17:07.554000000', + 'message': 'Patch Set 1:\n\n(3 comments)', + '_revision_number': 1 + } + ]; + flushAsynchronousOperations(); + }); + + test('expand/collapse all', function() { + var allMessageEls = + Polymer.dom(element.root).querySelectorAll('gr-message'); + for (var i = 0; i < allMessageEls.length; i++) { + allMessageEls[i].expanded = false; + } + MockInteractions.tap(allMessageEls[1]); + assert.isTrue(allMessageEls[1].expanded); + + MockInteractions.tap(element.$$('.header gr-button')); + allMessageEls = + Polymer.dom(element.root).querySelectorAll('gr-message'); + for (var i = 0; i < allMessageEls.length; i++) { + assert.isTrue(allMessageEls[i].expanded); + } + + MockInteractions.tap(element.$$('.header gr-button')); + allMessageEls = + Polymer.dom(element.root).querySelectorAll('gr-message'); + for (var i = 0; i < allMessageEls.length; i++) { + assert.isFalse(allMessageEls[i].expanded); + } + }); + + test('scroll to message', function() { + var allMessageEls = + Polymer.dom(element.root).querySelectorAll('gr-message'); + for (var i = 0; i < allMessageEls.length; i++) { + allMessageEls[i].expanded = false; + } + + var scrollToStub = sinon.stub(window, 'scrollTo'); + var highlightStub = sinon.stub(element, '_highlightEl'); + + element.scrollToMessage('invalid'); + + for (var i = 0; i < allMessageEls.length; i++) { + assert.isFalse(allMessageEls[i].expanded, + 'expected gr-message ' + i + ' to not be expanded'); + } + + var messageID = '47c43261_9593e420'; + element.scrollToMessage(messageID); + assert.isTrue( + element.$$('[data-message-id="' + messageID + '"]').expanded); + + assert.isTrue(scrollToStub.calledOnce); + assert.isTrue(highlightStub.calledOnce); + + scrollToStub.restore(); + highlightStub.restore(); + }); + + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html new file mode 100644 index 0000000..e93d008 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -0,0 +1,132 @@ +<!-- +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. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../behaviors/rest-client-behavior.html"> +<link rel="import" href="../../shared/gr-ajax/gr-ajax.html"> + +<dom-module id="gr-related-changes-list"> + <template> + <style> + :host { + display: block; + } + h3 { + margin: .5em 0 0; + } + section { + margin-bottom: 1em; + } + a { + display: block; + } + .relatedChanges a { + display: inline-block; + } + .strikethrough { + color: #666; + text-decoration: line-through; + } + .status { + color: #666; + font-weight: bold; + } + .notCurrent { + color: #e65100; + } + .indirectAncestor { + color: #33691e; + } + .submittable { + color: #1b5e20; + } + .hidden { + display: none; + } + </style> + <gr-ajax id="relatedXHR" + url="[[_computeRelatedURL(change._number, patchNum)]]" + last-response="{{_relatedResponse}}"></gr-ajax> + <gr-ajax id="submittedTogetherXHR" + url="[[_computeSubmittedTogetherURL(change._number)]]" + last-response="{{_submittedTogether}}"></gr-ajax> + <gr-ajax id="conflictsXHR" + url="/changes/" + params="[[_computeConflictsQueryParams(change._number)]]" + last-response="{{_conflicts}}"></gr-ajax> + <gr-ajax id="cherryPicksXHR" + url="/changes/" + params="[[_computeCherryPicksQueryParams(change.project, change.change_id, change._number)]]" + last-response="{{_cherryPicks}}"></gr-ajax> + <gr-ajax id="sameTopicXHR" + url="/changes/" + params="[[_computeSameTopicQueryParams(change.topic)]]" + last-response="{{_sameTopic}}"></gr-ajax> + + <div hidden$="[[!_loading]]">Loading...</div> + <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden> + <h4>Relation Chain</h4> + <template is="dom-repeat" items="[[_relatedResponse.changes]]" as="change"> + <div> + <a href$="[[_computeChangeURL(change._change_number, change._revision_number)]]" + class$="[[_computeLinkClass(change)]]"> + [[change.commit.subject]] + </a> + <span class$="[[_computeChangeStatusClass(change)]]"> + ([[_computeChangeStatus(change)]]) + </span> + </div> + </template> + </section> + <section hidden$="[[!_submittedTogether.length]]" hidden> + <h4>Submitted together</h4> + <template is="dom-repeat" items="[[_submittedTogether]]" as="change"> + <a href$="[[_computeChangeURL(change._number)]]" + class$="[[_computeLinkClass(change)]]"> + [[change.project]]: [[change.branch]]: [[change.subject]] + </a> + </template> + </section> + <section hidden$="[[!_sameTopic.length]]" hidden> + <h4>Same topic</h4> + <template is="dom-repeat" items="[[_sameTopic]]" as="change"> + <a href$="[[_computeChangeURL(change._number)]]" + class$="[[_computeLinkClass(change)]]"> + [[change.project]]: [[change.branch]]: [[change.subject]] + </a> + </template> + </section> + <section hidden$="[[!_conflicts.length]]" hidden> + <h4>Merge conflicts</h4> + <template is="dom-repeat" items="[[_conflicts]]" as="change"> + <a href$="[[_computeChangeURL(change._number)]]" + class$="[[_computeLinkClass(change)]]"> + [[change.subject]] + </a> + </template> + </section> + <section hidden$="[[!_cherryPicks.length]]" hidden> + <h4>Cherry picks</h4> + <template is="dom-repeat" items="[[_cherryPicks]]" as="change"> + <a href$="[[_computeChangeURL(change._number)]]" + class$="[[_computeLinkClass(change)]]"> + [[change.subject]] + </a> + </template> + </section> + </template> + <script src="gr-related-changes-list.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js new file mode 100644 index 0000000..f3a298e --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -0,0 +1,242 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-related-changes-list', + + properties: { + change: Object, + patchNum: String, + serverConfig: { + type: Object, + observer: '_serverConfigChanged', + }, + hidden: { + type: Boolean, + value: false, + reflectToAttribute: true, + }, + + _loading: Boolean, + _resolveServerConfigReady: Function, + _serverConfigReady: { + type: Object, + value: function() { + return new Promise(function(resolve) { + this._resolveServerConfigReady = resolve; + }.bind(this)); + } + }, + _connectedRevisions: { + type: Array, + computed: '_computeConnectedRevisions(change, patchNum, ' + + '_relatedResponse.changes)', + }, + _relatedResponse: Object, + _submittedTogether: Array, + _conflicts: Array, + _cherryPicks: Array, + _sameTopic: Array, + }, + + behaviors: [ + Gerrit.RESTClientBehavior, + ], + + observers: [ + '_resultsChanged(_relatedResponse.changes, _submittedTogether, ' + + '_conflicts, _cherryPicks, _sameTopic)', + ], + + reload: function() { + if (!this.change || !this.patchNum) { + return Promise.resolve(); + } + this._loading = true; + var promises = [ + this.$.relatedXHR.generateRequest().completes, + this.$.submittedTogetherXHR.generateRequest().completes, + this.$.conflictsXHR.generateRequest().completes, + this.$.cherryPicksXHR.generateRequest().completes, + ]; + + return this._serverConfigReady.then(function() { + if (this.change.topic && + !this.serverConfig.change.submit_whole_topic) { + return this.$.sameTopicXHR.generateRequest().completes; + } else { + this._sameTopic = []; + } + return Promise.resolve(); + }.bind(this)).then(Promise.all(promises)).then(function() { + this._loading = false; + }.bind(this)); + }, + + _computeRelatedURL: function(changeNum, patchNum) { + return this.changeBaseURL(changeNum, patchNum) + '/related'; + }, + + _computeSubmittedTogetherURL: function(changeNum) { + return this.changeBaseURL(changeNum) + '/submitted_together'; + }, + + _computeConflictsQueryParams: function(changeNum) { + var options = this.listChangesOptionsToHex( + this.ListChangesOption.CURRENT_REVISION, + this.ListChangesOption.CURRENT_COMMIT + ); + return { + O: options, + q: 'status:open is:mergeable conflicts:' + changeNum, + }; + }, + + _computeCherryPicksQueryParams: function(project, changeID, changeNum) { + var options = this.listChangesOptionsToHex( + this.ListChangesOption.CURRENT_REVISION, + this.ListChangesOption.CURRENT_COMMIT + ); + var query = [ + 'project:' + project, + 'change:' + changeID, + '-change:' + changeNum, + '-is:abandoned', + ].join(' '); + return { + O: options, + q: query + } + }, + + _computeSameTopicQueryParams: function(topic) { + var options = this.listChangesOptionsToHex( + this.ListChangesOption.LABELS, + this.ListChangesOption.CURRENT_REVISION, + this.ListChangesOption.CURRENT_COMMIT, + this.ListChangesOption.DETAILED_LABELS + ); + return { + O: options, + q: 'status:open topic:' + topic, + }; + }, + + _computeChangeURL: function(changeNum, patchNum) { + var urlStr = '/c/' + changeNum; + if (patchNum != null) { + urlStr += '/' + patchNum; + } + return urlStr; + }, + + _computeLinkClass: function(change) { + if (change.status == this.ChangeStatus.ABANDONED) { + return 'strikethrough'; + } + }, + + _computeChangeStatusClass: function(change) { + var classes = ['status']; + if (change._revision_number != change._current_revision_number) { + classes.push('notCurrent'); + } else if (this._isIndirectAncestor(change)) { + classes.push('indirectAncestor'); + } else if (change.submittable) { + classes.push('submittable'); + } else if (change.status == this.ChangeStatus.NEW) { + classes.push('hidden'); + } + return classes.join(' '); + }, + + _computeChangeStatus: function(change) { + switch (change.status) { + case this.ChangeStatus.MERGED: + return 'Merged'; + case this.ChangeStatus.ABANDONED: + return 'Abandoned'; + case this.ChangeStatus.DRAFT: + return 'Draft'; + } + if (change._revision_number != change._current_revision_number) { + return 'Not current'; + } else if (this._isIndirectAncestor(change)) { + return 'Indirect ancestor'; + } else if (change.submittable) { + return 'Submittable'; + } + return '' + }, + + _serverConfigChanged: function(config) { + this._resolveServerConfigReady(config); + }, + + _resultsChanged: function(related, submittedTogether, conflicts, + cherryPicks, sameTopic) { + var results = [ + related, + submittedTogether, + conflicts, + cherryPicks, + sameTopic + ]; + for (var i = 0; i < results.length; i++) { + if (results[i].length > 0) { + this.hidden = false; + return; + } + } + this.hidden = true; + }, + + _isIndirectAncestor: function(change) { + return this._connectedRevisions.indexOf(change.commit.commit) == -1; + }, + + _computeConnectedRevisions: function(change, patchNum, relatedChanges) { + var connected = []; + var changeRevision; + for (var rev in change.revisions) { + if (change.revisions[rev]._number == patchNum) { + changeRevision = rev; + } + } + var commits = relatedChanges.map(function(c) { return c.commit; }); + var pos = commits.length - 1; + + while (pos >= 0) { + var commit = commits[pos].commit; + connected.push(commit); + if (commit == changeRevision) { + break; + } + pos--; + } + while (pos >= 0) { + for (var i = 0; i < commits[pos].parents.length; i++) { + if (connected.indexOf(commits[pos].parents[i].commit) != -1) { + connected.push(commits[pos].commit); + break; + } + } + --pos; + } + return connected; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html new file mode 100644 index 0000000..7e0c236 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -0,0 +1,217 @@ +<!DOCTYPE html> +<!-- +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. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-related-changes-list</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-related-changes-list.html"> + +<test-fixture id="basic"> + <template> + <gr-related-changes-list></gr-related-changes-list> + </template> +</test-fixture> + +<script> + suite('gr-related-changes-list tests', function() { + var element; + + setup(function() { + element = fixture('basic'); + }); + + test('connected revisions', function() { + var change = { + revisions: { + 'e3c6d60783bfdec9ebae7dcfec4662360433449e': { + _number: 1, + }, + '26e5e4c9c7ae31cbd876271cca281ce22b413997': { + _number: 2, + }, + 'bf7884d695296ca0c91702ba3e2bc8df0f69a907': { + _number: 7, + }, + 'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': { + _number: 5, + }, + 'd6bcee67570859ccb684873a85cf50b1f0e96fda': { + _number: 6, + }, + 'cc960918a7f90388f4a9e05753d0f7b90ad44546': { + _number: 3, + }, + '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': { + _number: 4, + } + } + }; + var patchNum = 7; + var relatedChanges = [ + { + commit: { + commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8', + parents: [ + { + commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' + } + ], + }, + }, + { + commit: { + commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd', + parents: [ + { + commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' + } + ], + }, + }, + { + commit: { + commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb', + parents: [ + { + commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' + } + ], + }, + }, + { + commit: { + commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae', + parents: [ + { + commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907' + } + ], + }, + }, + { + commit: { + commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907', + parents: [ + { + commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce' + } + ], + }, + }, + { + commit: { + commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce', + parents: [ + { + commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75' + } + ], + }, + } + ]; + + var connectedChanges = + element._computeConnectedRevisions(change, patchNum, relatedChanges); + assert.deepEqual(connectedChanges, [ + '613bc4f81741a559c6667ac08d71dcc3348f73ce', + 'bf7884d695296ca0c91702ba3e2bc8df0f69a907', + 'bf7884d695296ca0c91702ba3e2bc8df0f69a907', + 'b0ccb183494a8e340b8725a2dc553967d61e6dae', + '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb', + '87ed20b241576b620bbaa3dfd47715ce6782b7dd', + '2cebeedfb1e80f4b872d0a13ade529e70652c0c8', + ]); + + patchNum = 4; + relatedChanges = [ + { + commit: { + commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8', + parents: [ + { + commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' + } + ], + }, + }, + { + commit: { + commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd', + parents: [ + { + commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' + } + ], + }, + }, + { + commit: { + commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb', + parents: [ + { + commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' + } + ], + }, + }, + { + commit: { + commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b', + parents: [ + { + commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6' + } + ], + }, + }, + { + commit: { + commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6', + parents: [ + { + commit: 'af815dac54318826b7f1fa468acc76349ffc588e' + } + ], + }, + }, + { + commit: { + commit: 'af815dac54318826b7f1fa468acc76349ffc588e', + parents: [ + { + commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c' + } + ], + }, + } + ]; + + connectedChanges = + element._computeConnectedRevisions(change, patchNum, relatedChanges); + assert.deepEqual(connectedChanges, [ + 'af815dac54318826b7f1fa468acc76349ffc588e', + '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6', + '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6', + 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b', + ]); + }); + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html new file mode 100644 index 0000000..ab21e6c --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -0,0 +1,148 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html"> +<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html"> +<link rel="import" href="../../../behaviors/rest-client-behavior.html"> +<link rel="import" href="../../shared/gr-ajax/gr-ajax.html"> +<link rel="import" href="../../shared/gr-button/gr-button.html"> +<link rel="import" href="../../shared/gr-request/gr-request.html"> + +<dom-module id="gr-reply-dialog"> + <style> + :host { + display: block; + max-height: 90vh; + } + :host([disabled]) { + pointer-events: none; + } + :host([disabled]) .container { + opacity: .5; + } + .container { + display: flex; + flex-direction: column; + max-height: 90vh; + } + section { + border-top: 1px solid #ddd; + padding: .5em .75em; + } + .textareaContainer, + .labelsContainer, + .actionsContainer { + flex-shrink: 0; + } + .textareaContainer { + position: relative; + } + iron-autogrow-textarea { + padding: 0; + font-family: var(--monospace-font-family); + } + .message { + border: none; + width: 100%; + } + .labelContainer:not(:first-of-type) { + margin-top: .5em; + } + .labelName { + display: inline-block; + width: 7em; + margin-right: .5em; + white-space: nowrap; + } + iron-selector { + display: inline-flex; + } + iron-selector > gr-button { + margin-right: .25em; + } + iron-selector > gr-button:first-of-type { + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + } + iron-selector > gr-button:last-of-type { + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + } + iron-selector > gr-button.iron-selected { + background-color: #ddd; + } + .draftsContainer { + overflow-y: auto; + } + .draftsContainer h3 { + margin-top: .25em; + } + .actionsContainer { + display: flex; + justify-content: space-between; + } + .action:link, + .action:visited { + color: #00e; + } + </style> + <template> + <gr-ajax id="draftsXHR" + url="[[_computeDraftsURL(changeNum)]]" + last-response="{{_drafts}}"></gr-ajax> + <div class="container"> + <section class="textareaContainer"> + <iron-autogrow-textarea + id="textarea" + class="message" + placeholder="Say something..." + disabled="{{disabled}}" + rows="4" + max-rows="15" + bind-value="{{draft}}"></iron-autogrow-textarea> + </section> + <section class="labelsContainer"> + <template is="dom-repeat" + items="[[_computeLabelArray(permittedLabels)]]" as="label"> + <div class="labelContainer"> + <span class="labelName">[[label]]</span> + <iron-selector data-label$="[[label]]" + selected="[[_computeIndexOfLabelValue(labels, permittedLabels, label, _account)]]"> + <template is="dom-repeat" + items="[[_computePermittedLabelValues(permittedLabels, label)]]" + as="value"> + <gr-button data-value$="[[value]]">[[value]]</gr-button> + </template> + </iron-selector> + </div> + </template> + </section> + <section class="draftsContainer" hidden$="[[_computeHideDraftList(_drafts)]]"> + <h3>[[_computeDraftsTitle(_drafts)]]</h3> + <gr-comment-list + comments="[[_drafts]]" + change-num="[[changeNum]]" + patch-num="[[patchNum]]"></gr-comment-list> + </section> + <section class="actionsContainer"> + <gr-button primary class="action send" on-tap="_sendTapHandler">Send</gr-button> + <gr-button class="action cancel" on-tap="_cancelTapHandler">Cancel</gr-button> + </section> + </div> + </template> + <script src="gr-reply-dialog.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js new file mode 100644 index 0000000..3cd6e12 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -0,0 +1,171 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-reply-dialog', + + /** + * Fired when a reply is successfully sent. + * + * @event send + */ + + /** + * Fired when the user presses the cancel button. + * + * @event cancel + */ + + properties: { + changeNum: String, + patchNum: String, + disabled: { + type: Boolean, + value: false, + reflectToAttribute: true, + }, + draft: { + type: String, + value: '', + }, + labels: Object, + permittedLabels: Object, + + _account: Object, + _drafts: Object, + _xhrPromise: Object, // Used for testing. + }, + + behaviors: [ + Gerrit.RESTClientBehavior, + ], + + ready: function() { + app.accountReady.then(function() { + this._account = app.account; + }.bind(this)); + }, + + reload: function() { + return this.$.draftsXHR.generateRequest().completes; + }, + + focus: function() { + this.async(function() { + this.$.textarea.textarea.focus(); + }.bind(this)); + }, + + _computeDraftsURL: function(changeNum) { + return '/changes/' + changeNum + '/drafts'; + }, + + _computeHideDraftList: function(drafts) { + return Object.keys(drafts || {}).length == 0; + }, + + _computeDraftsTitle: function(drafts) { + var total = 0; + for (var file in drafts) { + total += drafts[file].length; + } + if (total == 0) { return ''; } + if (total == 1) { return '1 Draft'; } + if (total > 1) { return total + ' Drafts'; } + }, + + _computeLabelArray: function(labelsObj) { + return Object.keys(labelsObj).sort(); + }, + + _computeIndexOfLabelValue: function( + labels, permittedLabels, labelName, account) { + var t = labels[labelName]; + if (!t) { return null; } + var labelValue = t.default_value; + + // Is there an existing vote for the current user? If so, use that. + var votes = labels[labelName]; + if (votes.all && votes.all.length > 0) { + for (var i = 0; i < votes.all.length; i++) { + if (votes.all[i]._account_id == account._account_id) { + labelValue = votes.all[i].value; + break; + } + } + } + + var len = permittedLabels[labelName] != null ? + permittedLabels[labelName].length : 0; + for (var i = 0; i < len; i++) { + var val = parseInt(permittedLabels[labelName][i], 10); + if (val == labelValue) { + return i; + } + } + return null; + }, + + _computePermittedLabelValues: function(permittedLabels, label) { + return permittedLabels[label]; + }, + + _cancelTapHandler: function(e) { + e.preventDefault(); + this._drafts = null; + this.fire('cancel', null, {bubbles: false}); + }, + + _sendTapHandler: function(e) { + e.preventDefault(); + var obj = { + drafts: 'PUBLISH_ALL_REVISIONS', + labels: {}, + }; + for (var label in this.permittedLabels) { + var selectorEl = this.$$('iron-selector[data-label="' + label + '"]'); + var selectedVal = selectorEl.selectedItem.getAttribute('data-value'); + selectedVal = parseInt(selectedVal, 10); + obj.labels[label] = selectedVal; + } + if (this.draft != null) { + obj.message = this.draft; + } + this.disabled = true; + this._send(obj).then(function(req) { + this.fire('send', null, {bubbles: false}); + this.draft = ''; + this.disabled = false; + this._drafts = null; + }.bind(this)).catch(function(err) { + alert('Oops. Something went wrong. Check the console and bug the ' + + 'PolyGerrit team for assistance.'); + throw err; + }.bind(this)); + }, + + _send: function(payload) { + var xhr = document.createElement('gr-request'); + this._xhrPromise = xhr.send({ + method: 'POST', + url: this.changeBaseURL(this.changeNum, this.patchNum) + '/review', + body: payload, + }); + + return this._xhrPromise; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html new file mode 100644 index 0000000..a6f4671 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -0,0 +1,153 @@ +<!DOCTYPE html> +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-reply-dialog</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../scripts/fake-app.js"></script> +<script src="../../../scripts/util.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-reply-dialog.html"> + +<test-fixture id="basic"> + <template> + <gr-reply-dialog></gr-reply-dialog> + </template> +</test-fixture> + +<script> + suite('gr-reply-dialog tests', function() { + var element; + var server; + + setup(function() { + element = fixture('basic'); + element.changeNum = 42; + element.patchNum = 1; + element.labels = { + Verified: { + values: { + '-1': 'Fails', + ' 0': 'No score', + '+1': 'Verified' + }, + default_value: 0 + }, + 'Code-Review': { + values: { + '-2': 'Do not submit', + '-1': 'I would prefer that you didn\'t submit this', + ' 0': 'No score', + '+1': 'Looks good to me, but someone else must approve', + '+2': 'Looks good to me, approved' + }, + default_value: 0 + } + }; + element.permittedLabels = { + 'Code-Review': [ + '-1', + ' 0', + '+1' + ], + Verified: [ + '-1', + ' 0', + '+1' + ] + }; + + server = sinon.fakeServer.create(); + server.respondWith( + 'POST', + '/changes/42/revisions/1/review', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + '{' + + '"labels": {' + + '"Code-Review": -1,' + + '"Verified": -1' + + '}' + + '}' + ] + ); + + // Allow the elements created by dom-repeat to be stamped. + flushAsynchronousOperations(); + }); + + teardown(function() { + server.restore(); + }); + + test('cancel event', function(done) { + element.addEventListener('cancel', function() { done(); }); + MockInteractions.tap(element.$$('.cancel')); + }); + + test('label picker', function(done) { + // Async tick is needed because iron-selector content is distributed and + // distributed content requires an observer to be set up. + element.async(function() { + for (var label in element.permittedLabels) { + assert.ok(element.$$('iron-selector[data-label="' + label + '"]'), + label); + } + element.draft = 'I wholeheartedly disapprove'; + MockInteractions.tap(element.$$( + 'iron-selector[data-label="Code-Review"] > ' + + 'gr-button[data-value="-1"]')); + MockInteractions.tap(element.$$( + 'iron-selector[data-label="Verified"] > ' + + 'gr-button[data-value="-1"]')); + + // This is needed on non-Blink engines most likely due to the ways in + // which the dom-repeat elements are stamped. + element.async(function() { + MockInteractions.tap(element.$$('.send')); + assert.isTrue(element.disabled); + + server.respond(); + + element._xhrPromise.then(function(req) { + assert.isFalse(element.disabled, + 'Element should be enabled when done sending reply.'); + assert.equal(req.status, 200); + assert.equal(req.url, '/changes/42/revisions/1/review'); + var reqObj = JSON.parse(req.xhr.requestBody); + assert.deepEqual(reqObj, { + drafts: 'PUBLISH_ALL_REVISIONS', + labels: { + 'Code-Review': -1, + 'Verified': -1 + }, + message: 'I wholeheartedly disapprove' + }); + assert.equal(req.response.labels['Code-Review'], -1); + assert.equal(req.response.labels.Verified, -1); + done(); + }); + }, 1); + }, 1); + }); + }); +</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html new file mode 100644 index 0000000..d20fd01 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -0,0 +1,118 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<link rel="import" href="../../../bower_components/polymer/polymer.html"> +<link rel="import" href="../../../bower_components/iron-input/iron-input.html"> +<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html"> +<link rel="import" href="../../shared/gr-ajax/gr-ajax.html"> +<link rel="import" href="../../shared/gr-button/gr-button.html"> +<link rel="import" href="../../shared/gr-request/gr-request.html"> + +<dom-module id="gr-reviewer-list"> + <style> + :host { + display: block; + } + :host([disabled]) { + opacity: .8; + pointer-events: none; + } + .autocompleteContainer { + position: relative; + } + .inputContainer { + display: flex; + margin-top: .25em; + } + .inputContainer input { + flex: 1; + font: inherit; + } + .dropdown { + background-color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, .3); + position: absolute; + left: 0; + top: 100%; + } + .dropdown .reviewer { + cursor: pointer; + padding: .5em .75em; + } + .dropdown .reviewer[selected] { + background-color: #ccc; + } + .remove, + .cancel { + color: #999; + } + .remove { + font-size: .9em; + } + .cancel { + font-size: 2em; + line-height: 1; + padding: 0 .15em; + text-decoration: none; + } + </style> + <template> + <gr-ajax id="autocompleteXHR" + url="[[_computeAutocompleteURL(change)]]" + params="[[_computeAutocompleteParams(_inputVal)]]" + on-response="_handleResponse"></gr-ajax> + + <template is="dom-repeat" items="[[_reviewers]]" as="reviewer"> + <div class="reviewer"> + <gr-account-link account="[[reviewer]]" show-email></gr-account-link> + <gr-button link + class="remove" + data-account-id$="[[reviewer._account_id]]" + on-tap="_handleRemoveTap" + hidden$="[[!_computeCanRemoveReviewer(reviewer, mutable)]]">remove</gr-buttom> + </div> + </template> + <div class="controlsContainer" hidden$="[[!mutable]]"> + <div class="autocompleteContainer" hidden$="[[!_showInput]]"> + <div class="inputContainer"> + <input is="iron-input" id="input" + bind-value="{{_inputVal}}" disabled$="[[disabled]]"> + <gr-button link class="cancel" on-tap="_handleCancelTap">×</gr-button> + </div> + <div class="dropdown" hidden$="[[_hideAutocomplete]]"> + <template is="dom-repeat" items="[[_autocompleteData]]" as="reviewer"> + <div class="reviewer" + data-index$="[[index]]" + on-mouseenter="_handleMouseEnterItem" + on-tap="_handleItemTap" + selected$="[[_computeSelected(index, _selectedIndex)]]"> + <template is="dom-if" if="[[reviewer.account]]"> + <gr-account-label + account="[[reviewer.account]]" show-email></gr-account-label> + </template> + <template is="dom-if" if="[[reviewer.group]]"> + <span>[[reviewer.group.name]] (group)</span> + </template> + </div> + </template> + </div> + </div> + <gr-button link id="addReviewer" class="addReviewer" on-tap="_handleAddTap" + hidden$="[[_showInput]]">Add reviewer</gr-button> + </div> + </template> + <script src="gr-reviewer-list.js"></script> +</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js new file mode 100644 index 0000000..00fc12e --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -0,0 +1,344 @@ +// 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. +(function() { + 'use strict'; + + Polymer({ + is: 'gr-reviewer-list', + + properties: { + change: Object, + mutable: { + type: Boolean, + value: false, + }, + disabled: { + type: Boolean, + value: false, + reflectToAttribute: true, + }, + suggestFrom: { + type: Number, + value: 3, + }, + + _reviewers: { + type: Array, + value: function() { return []; }, + }, + _autocompleteData: { + type: Array, + value: function() { return []; }, + observer: '_autocompleteDataChanged', + }, + _inputVal: { + type: String, + value: '', + observer: '_inputValChanged', + }, + _inputRequestHandle: Number, + _inputRequestTimeout: { + type: Number, + value: 250, + }, + _showInput: { + type: Boolean, + value: false, + }, + _hideAutocomplete: { + type: Boolean, + value: true, + observer: '_hideAutocompleteChanged', + }, + _selectedIndex: { + type: Number, + value: 0, + }, + _boundBodyClickHandler: { + type: Function, + value: function() { + return this._handleBodyClick.bind(this); + }, + }, + + // Used for testing. + _lastAutocompleteRequest: Object, + _xhrPromise: Object, + }, + + behaviors: [ + Gerrit.KeyboardShortcutBehavior, + ], + + observers: [ + '_reviewersChanged(change.reviewers.*, change.owner)', + ], + + detached: function() { + this._clearInputRequestHandle(); + }, + + _clearInputRequestHandle: function() { + if (this._inputRequestHandle != null) { + this.cancelAsync(this._inputRequestHandle); + this._inputRequestHandle = null; + } + }, + + _reviewersChanged: function(changeRecord, owner) { + var result = []; + var reviewers = changeRecord.base; + for (var key in reviewers) { + if (key == 'REVIEWER' || key == 'CC') { + result = result.concat(reviewers[key]); + } + } + this._reviewers = result.filter(function(reviewer) { + return reviewer._account_id != owner._account_id; + }); + }, + + _computeCanRemoveReviewer: function(reviewer, mutable) { + if (!mutable) { return false; } + + for (var i = 0; i < this.change.removable_reviewers.length; i++) { + if (this.change.removable_reviewers[i]._account_id == + reviewer._account_id) { + return true; + } + } + return false; + }, + + _computeAutocompleteURL: function(change) { + return '/changes/' + change._number + '/suggest_reviewers'; + }, + + _computeAutocompleteParams: function(inputVal) { + return { + n: 10, // Return max 10 results + q: inputVal, + }; + }, + + _computeSelected: function(index, selectedIndex) { + return index == selectedIndex; + }, + + _handleResponse: function(e) { + this._autocompleteData = e.detail.response.filter(function(reviewer) { + var account = reviewer.account; + if (!account) { return true; } + for (var i = 0; i < this._reviewers.length; i++) { + if (account._account_id == this.change.owner._account_id || + account._account_id == this._reviewers[i]._account_id) { + return false; + } + } + return true; + }, this); + }, + + _handleBodyClick: function(e) { + var eventPath = Polymer.dom(e).path; + for (var i = 0; i < eventPath.length; i++) { + if (eventPath[i] == this) { + return; + } + } + this._selectedIndex = -1; + this._autocompleteData = []; + }, + + _handleRemoveTap: function(e) { + e.preventDefault(); + var target = Polymer.dom(e).rootTarget; + var accountID = parseInt(target.getAttribute('data-account-id'), 10); + this._send('DELETE', this._restEndpoint(accountID)).then(function(req) { + var reviewers = this.change.reviewers; + ['REVIEWER', 'CC'].forEach(function(type) { + reviewers[type] = reviewers[type] || []; + for (var i = 0; i < reviewers[type].length; i++) { + if (reviewers[type][i]._account_id == accountID) { + this.splice('change.reviewers.' + type, i, 1); + break; + } + } + }, this); + }.bind(this)).catch(function(err) { + alert('Oops. Something went wrong. Check the console and bug the ' + + 'PolyGerrit team for assistance.'); + throw err; + }.bind(this)); + }, + + _handleAddTap: function(e) { + e.preventDefault(); + this._showInput = true; + this.$.input.focus(); + }, + + _handleCancelTap: function(e) { + e.preventDefault(); + this._cancel(); + }, + + _handleMouseEnterItem: function(e) { + this._selectedIndex = + parseInt(Polymer.dom(e).rootTarget.getAttribute('data-index'), 10); + }, + + _handleItemTap: function(e) { + var reviewerEl; + var eventPath = Polymer.dom(e).path; + for (var i = 0; i < eventPath.length; i++) { + var el = eventPath[i]; + if (el.classList && el.classList.contains('reviewer')) { + reviewerEl = el; + break; + } + } + this._selectedIndex = + parseInt(reviewerEl.getAttribute('data-index'), 10); + this._sendAddRequest(); + }, + + _autocompleteDataChanged: function(data) { + this._hideAutocomplete = data.length == 0; + }, + + _hideAutocompleteChanged: function(hidden) { + if (hidden) { + document.body.removeEventListener('click', + this._boundBodyClickHandler); + this._selectedIndex = -1; + } else { + document.body.addEventListener('click', this._boundBodyClickHandler); + this._selectedIndex = 0; + } + }, + + _inputValChanged: function(val) { + var sendRequest = function() { + if (this.disabled || val == null || val.trim().length == 0) { + return; + } + if (val.length < this.suggestFrom) { + this._clearInputRequestHandle(); + this._hideAutocomplete = true; + this._selectedIndex = -1; + return; + } + this._lastAutocompleteRequest = + this.$.autocompleteXHR.generateRequest(); + }.bind(this); + + this._clearInputRequestHandle(); + if (this._inputRequestTimeout == 0) { + sendRequest(); + } else { + this._inputRequestHandle = + this.async(sendRequest, this._inputRequestTimeout); + } + }, + + _handleKey: function(e) { + if (this._hideAutocomplete) { + if (e.keyCode == 27) { // 'esc' + e.preventDefault(); + this._cancel(); + } + return; + } + + switch (e.keyCode) { + case 38: // 'up': + e.preventDefault(); + this._selectedIndex = Math.max(this._selectedIndex - 1, 0); + break; + case 40: // 'down' + e.preventDefault(); + this._selectedIndex = Math.min(this._selectedIndex + 1, + this._autocompleteData.length - 1); + break; + case 27: // 'esc' + e.preventDefault(); + this._hideAutocomplete = true; + break; + case 13: // 'enter' + e.preventDefault(); + this._sendAddRequest(); + break; + } + }, + + _cancel: function() { + this._showInput = false; + this._selectedIndex = 0; + this._inputVal = ''; + this._autocompleteData = []; + this.$.addReviewer.focus(); + }, + + _sendAddRequest: function() { + this._clearInputRequestHandle(); + + var reviewerID; + var reviewer = this._autocompleteData[this._selectedIndex]; + if (reviewer.account) { + reviewerID = reviewer.account._account_id; + } else if (reviewer.group) { + reviewerID = reviewer.group.id; + } + this._autocompleteData = []; + this._send('POST', this._restEndpoint(), reviewerID).then(function(req) { + this.change.reviewers.CC = this.change.reviewers.CC || []; + req.response.reviewers.forEach(function(r) { + this.push('change.removable_reviewers', r); + this.push('change.reviewers.CC', r); + }, this); + this._inputVal = ''; + this.$.input.focus(); + }.bind(this)).catch(function(err) { + // TODO(andybons): Use the message returned by the server. + alert('Unable to add ' + reviewerID + ' as a reviewer.'); + throw err; + }.bind(this)); + }, + + _send: function(method, url, reviewerID) { + this.disabled = true; + var request = document.createElement('gr-request'); + var opts = { + method: method, + url: url, + }; + if (reviewerID) { + opts.body = {reviewer: reviewerID}; + } + this._xhrPromise = request.send(opts); + var enableEl = function() { this.disabled = false; }.bind(this); + this._xhrPromise.then(enableEl).catch(enableEl); + return this._xhrPromise; + }, + + _restEndpoint: function(id) { + var path = '/changes/' + this.change._number + '/reviewers'; + if (id) { + path += '/' + id; + } + return path; + }, + }); +})();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html new file mode 100644 index 0000000..898d328 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -0,0 +1,278 @@ +<!DOCTYPE html> +<!-- +Copyright (C) 2015 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-reviewer-list</title> + +<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script> +<script src="../../../bower_components/web-component-tester/browser.js"></script> +<script src="../../../scripts/util.js"></script> + +<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html"> +<link rel="import" href="gr-reviewer-list.html"> + +<test-fixture id="basic"> + <template> + <gr-reviewer-list></gr-reviewer-list> + </template> +</test-fixture> + +<script> + suite('gr-reviewer-list tests', function() { + var element; + var server; + + setup(function() { + element = fixture('basic'); + + server = sinon.fakeServer.create(); + server.respondWith( + 'GET', + /\/changes\/42\/suggest_reviewers\?n=10&q=andy(.*)/, + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + JSON.stringify([ + { + account: { + _account_id: 1021482, + name: 'Andrew Bonventre', + email: 'andybons@chromium.org', + } + }, + { + account: { + _account_id: 1021863, + name: 'Andrew Bonventre', + email: 'andybons@google.com', + } + }, + { + group: { + id: 'c7af6dd375c092ff3b23c0937aa910693dc0c41b', + name: 'andy', + } + } + ]), + ] + ); + server.respondWith( + 'POST', + '/changes/42/reviewers', + [ + 200, + {'Content-Type': 'application/json'}, + ')]}\'\n' + + JSON.stringify({ + reviewers: [{ + _account_id: 1021482, + approvals: { + 'Code-Review': ' 0' + }, + email: 'andybons@chromium.org', + name: 'Andrew Bonventre', + }] + }), + ] + ); + server.respondWith( + 'DELETE', + '/changes/42/reviewers/1021482', + [ + 204, + {'Content-Type': 'application/json'}, + ')]}\'\n{}', + ] + ); + }); + + teardown(function() { + server.restore(); + }); + + test('controls hidden on immutable element', function() { + element.mutable = false; + assert.isTrue(element.$$('.controlsContainer').hasAttribute('hidden')); + element.mutable = true; + assert.isFalse(element.$$('.controlsContainer').hasAttribute('hidden')); + }); + + function getActiveElement() { + return document.activeElement.shadowRoot ? + document.activeElement.shadowRoot.activeElement : + document.activeElement; + } + + test('show/hide input', function() { + element.mutable = true; + assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden')); + assert.isTrue( + element.$$('.autocompleteContainer').hasAttribute('hidden')); + assert.notEqual(getActiveElement().id, 'input'); + MockInteractions.tap(element.$$('.addReviewer')); + assert.isTrue(element.$$('.addReviewer').hasAttribute('hidden')); + assert.isFalse( + element.$$('.autocompleteContainer').hasAttribute('hidden')); + assert.equal(getActiveElement().id, 'input'); + MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc' + assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden')); + assert.isTrue( + element.$$('.autocompleteContainer').hasAttribute('hidden')); + assert.equal(getActiveElement().id, 'addReviewer'); + }); + + test('only show remove for removable reviewers', function() { + element.mutable = true; + element.change = { + owner: { + _account_id: 1, + }, + reviewers: { + 'REVIEWER': [ + { + _account_id: 2, + name: 'Bojack Horseman', + email: 'SecretariatRulez96@hotmail.com', + }, + { + _account_id: 3, + name: 'Pinky Penguin', + }, + ], + 'CC': [ + { + _account_id: 4, + name: 'Diane Nguyen', + email: 'macarthurfellow2B@juno.com', + }, + ] + }, + removable_reviewers: [ + { + _account_id: 3, + name: 'Pinky Penguin', + }, + { + _account_id: 4, + name: 'Diane Nguyen', + email: 'macarthurfellow2B@juno.com', + }, + ] + }; + flushAsynchronousOperations(); + var removeEls = + Polymer.dom(element.root).querySelectorAll('.reviewer > .remove'); + assert.equal(removeEls.length, 3); + Array.from(removeEls).forEach(function(el) { + var accountID = parseInt(el.getAttribute('data-account-id'), 10); + assert.ok(accountID); + if (accountID == 2) { + assert.isTrue(el.hasAttribute('hidden')); + } else { + assert.isFalse(el.hasAttribute('hidden')); + } + }); + }); + + test('autocomplete starts at >= 3 chars', function() { + element._inputRequestTimeout = 0; + element._mutable = true; + var genRequestStub = sinon.stub( + element.$.autocompleteXHR, + 'generateRequest', + function() { + assert(false, 'generateRequest should not be called for input ' + + 'lengths of less than 3 chars'); + } + ); + element._inputVal = 'fo'; + flushAsynchronousOperations(); + genRequestStub.restore(); + }); + + test('add/remove reviewer flow', function(done) { + element.change = { + _number: 42, + reviewers: {}, + removable_reviewers: [], + owner: {_account_id: 0}, + }; + element._inputRequestTimeout = 0; + element._mutable = true; + MockInteractions.tap(element.$$('.addReviewer')); + flushAsynchronousOperations(); + element._inputVal = 'andy'; + server.respond(); + + element._lastAutocompleteRequest.completes.then(function() { + flushAsynchronousOperations(); + assert.isFalse(element.$$('.dropdown').hasAttribute('hidden')); + var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer'); + assert.equal(itemEls.length, 3); + assert.isTrue(itemEls[0].hasAttribute('selected')); + assert.isFalse(itemEls[1].hasAttribute('selected')); + + MockInteractions.pressAndReleaseKeyOn(element, 40); // 'down' + assert.isFalse(itemEls[0].hasAttribute('selected')); + assert.isTrue(itemEls[1].hasAttribute('selected')); + + MockInteractions.pressAndReleaseKeyOn(element, 38); // 'up' + assert.isTrue(itemEls[0].hasAttribute('selected')); + assert.isFalse(itemEls[1].hasAttribute('selected')); + + MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc' + assert.isTrue(element.$$('.dropdown').hasAttribute('hidden')); + + element._inputVal = 'andyb'; + server.respond(); + + element._lastAutocompleteRequest.completes.then(function() { + assert.isFalse(element.$$('.dropdown').hasAttribute('hidden')); + var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer'); + assert.equal(itemEls.length, 3); + assert.isTrue(itemEls[0].hasAttribute('selected')); + assert.isFalse(itemEls[1].hasAttribute('selected')); + MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter' + assert.isTrue(element.disabled); + server.respond(); + + element._xhrPromise.then(function() { + assert.isFalse(element.disabled); + flushAsynchronousOperations(); + var reviewerEls = + Polymer.dom(element.root).querySelectorAll('.reviewer'); + assert.equal(reviewerEls.length, 1); + MockInteractions.tap(element.$$('.reviewer > .remove')); + flushAsynchronousOperations(); + assert.isTrue(element.disabled); + server.respond(); + + element._xhrPromise.then(function() { + flushAsynchronousOperations(); + assert.isFalse(element.disabled); + var reviewerEls = + Polymer.dom(element.root).querySelectorAll('.reviewer'); + assert.equal(reviewerEls.length, 0); + done(); + }); + }); + }); + }); + }); + }); +</script>