Introduce gr-editor-view
Adds a new element subdirectory, /edit/, and the gr-editor-view. This
new view supports editing a file with a textarea, as well as editing the
file path.
Mechanism for loading a file into this view will come in a later change.
Bug: Issue 4437
Change-Id: I8ab480ce2aa7a6df90930b02c8961f395571f5a8
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
new file mode 100644
index 0000000..ff144c3
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -0,0 +1,102 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+
+<dom-module id="gr-editor-view">
+ <template>
+ <style include="shared-styles">
+ :host {
+ background-color: var(--view-background-color);
+ }
+ gr-fixed-panel {
+ background-color: #fff;
+ border-bottom: 1px #eee solid;
+ z-index: 1;
+ }
+ header,
+ .subHeader {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ padding: .75em var(--default-horizontal-margin);
+ }
+ header gr-editable-label {
+ font-size: 1.2em;
+ font-weight: bold;
+ }
+ .textareaWrapper {
+ margin: var(--default-horizontal-margin);
+ }
+ .textareaWrapper textarea {
+ border: 1px solid #ddd;
+ border-radius: 3px;
+ box-sizing: border-box;
+ font-family: var(--monospace-font-family);
+ min-height: 60vh;
+ resize: none;
+ white-space: pre;
+ width: 100%;
+ }
+ .textareaWrapper textarea:focus {
+ outline: none;
+ }
+ .textareaWrapper .editButtons {
+ display: none;
+ }
+ .rightControls {
+ justify-content: flex-end
+ }
+ </style>
+ <gr-fixed-panel
+ class$="[[_computeContainerClass(_editLoaded)]]"
+ floating-disabled="[[_panelFloatingDisabled]]"
+ keep-on-scroll
+ ready-for-measure="[[!_loading]]">
+ <header>
+ <gr-editable-label
+ label-text="File path"
+ value="[[_path]]"
+ placeholder="File path..."
+ on-changed="_handlePathChanged"></gr-editable-label>
+ <span class="rightControls">
+ <gr-button
+ id="save"
+ disabled$="[[_saveDisabled]]"
+ primary
+ on-tap="_saveEdit">Save</gr-button>
+ <gr-button id="cancel" on-tap="_handleCancelTap">Cancel</gr-button>
+ </span>
+ </header>
+ </gr-fixed-panel>
+ <div class="textareaWrapper">
+ <textarea id="file">{{_newContent}}</textarea>
+ </div>
+ <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+ </template>
+ <script src="gr-editor-view.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
new file mode 100644
index 0000000..b8a3f9d
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -0,0 +1,139 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+ 'use strict';
+
+ Polymer({
+ is: 'gr-editor-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',
+ },
+
+ _change: Object,
+ _changeEditDetail: Object,
+ _changeNum: String,
+ _loggedIn: Boolean,
+ _path: String,
+ _content: String,
+ _newContent: String,
+ _saveDisabled: {
+ type: Boolean,
+ value: true,
+ computed: '_computeSaveDisabled(_content, _newContent)',
+ },
+ },
+
+ behaviors: [
+ Gerrit.KeyboardShortcutBehavior,
+ Gerrit.PatchSetBehavior,
+ Gerrit.PathListBehavior,
+ ],
+
+ attached() {
+ this._getLoggedIn().then(loggedIn => { this._loggedIn = loggedIn; });
+ },
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ },
+
+ _paramsChanged(value) {
+ if (value.view !== Gerrit.Nav.View.EDIT) { return; }
+
+ this._changeNum = value.changeNum;
+ this._path = value.path;
+
+ // NOTE: This may be called before attachment (e.g. while parentElement is
+ // null). Fire title-change in an async so that, if attachment to the DOM
+ // has been queued, the event can bubble up to the handler in gr-app.
+ this.async(() => {
+ const title = `Editing ${this.computeTruncatedPath(this._path)}`;
+ this.fire('title-change', {title});
+ });
+
+ const promises = [];
+
+ promises.push(this._getChangeDetail(this._changeNum));
+ promises.push(this._getFileContent(this._changeNum, this._path)
+ .then(fileContent => {
+ this._content = fileContent;
+ this._newContent = fileContent;
+ }));
+ return Promise.all(promises);
+ },
+
+ _getChangeDetail(changeNum) {
+ return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+ this._change = change;
+ });
+ },
+
+ _handlePathChanged(e) {
+ const path = e.detail;
+ if (path === this._path) { return Promise.resolve(); }
+ return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
+ this._path, path).then(res => {
+ if (!res.ok) { return; }
+ this._viewEditInChangeView();
+ });
+ },
+
+ _viewEditInChangeView() {
+ Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME);
+ },
+
+ _getFileContent(changeNum, path) {
+ return this.$.restAPI.getFileInChangeEdit(changeNum, path).then(res => {
+ if (!res.ok) {
+ if (res.status === 404) {
+ // No edits have been made yet.
+ return this.$.restAPI.getFileInChangeEdit(changeNum, path, true)
+ .then(res => res.text().then(text => atob(text)));
+ }
+ return '';
+ }
+ return res.text().then(text => atob(text));
+ });
+ },
+
+ _saveEdit() {
+ return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
+ this._newContent).then(res => {
+ if (!res.ok) { return; }
+ this._viewEditInChangeView();
+ });
+ },
+
+ _computeSaveDisabled(content, newContent) {
+ return content === newContent;
+ },
+
+ _handleCancelTap() {
+ // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+ this._viewEditInChangeView();
+ },
+ });
+})();
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
new file mode 100644
index 0000000..e3e6474
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -0,0 +1,183 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-editor-view</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="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-editor-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+ <template>
+ <gr-editor-view></gr-editor-view>
+ </template>
+</test-fixture>
+
+<script>
+suite('gr-editor-view tests', () => {
+ let element;
+ let sandbox;
+ let savePathStub;
+ let saveFileStub;
+ let changeDetailStub;
+ let navigateStub;
+ const mockParams = {
+ changeNum: '42',
+ path: 'foo/bar.baz',
+ };
+
+ setup(() => {
+ stub('gr-rest-api-interface', {
+ getLoggedIn() { return Promise.resolve(true); },
+ });
+ sandbox = sinon.sandbox.create();
+ element = fixture('basic');
+ savePathStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
+ saveFileStub = sandbox.stub(element.$.restAPI, 'saveChangeEdit');
+ changeDetailStub = sandbox.stub(element.$.restAPI, 'getDiffChangeDetail');
+ navigateStub = sandbox.stub(element, '_viewEditInChangeView');
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ suite('_paramsChanged', () => {
+ test('incorrect view returns immediately', () => {
+ element._paramsChanged(
+ Object.assign({}, mockParams, {view: Gerrit.Nav.View.DIFF}));
+ assert.notOk(element._changeNum);
+ });
+
+ test('good params proceed', () => {
+ changeDetailStub.returns(Promise.resolve({}));
+ const fileStub = sandbox.stub(element, '_getFileContent')
+ .returns(Promise.resolve('text'));
+
+ const promises = element._paramsChanged(
+ Object.assign({}, mockParams, {view: Gerrit.Nav.View.EDIT}));
+
+ flushAsynchronousOperations();
+ assert.equal(element._changeNum, mockParams.changeNum);
+ assert.equal(element._path, mockParams.path);
+ assert.deepEqual(changeDetailStub.lastCall.args[0],
+ mockParams.changeNum);
+ assert.deepEqual(fileStub.lastCall.args,
+ [mockParams.changeNum, mockParams.path]);
+
+ return promises.then(() => {
+ assert.equal(element._content, 'text');
+ assert.equal(element._newContent, 'text');
+ });
+ });
+ });
+
+ test('edit file path', done => {
+ element._changeNum = mockParams.changeNum;
+ element._path = mockParams.path;
+ savePathStub.onFirstCall().returns(Promise.resolve({}));
+ savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+ // Calling with the same path should not navigate.
+ element._handlePathChanged({detail: mockParams.path}).then(() => {
+ assert.isFalse(savePathStub.called);
+ // !ok response
+ element._handlePathChanged({detail: 'newPath'}).then(() => {
+ assert.isTrue(savePathStub.called);
+ assert.isFalse(navigateStub.called);
+ // ok response
+ element._handlePathChanged({detail: 'newPath'}).then(() => {
+ assert.isTrue(navigateStub.called);
+ done();
+ });
+ });
+ });
+ });
+
+ suite('edit file content', () => {
+ const originalText = 'file text';
+ const newText = 'file text changed';
+
+ setup(() => {
+ element._changeNum = mockParams.changeNum;
+ element._path = mockParams.path;
+ element._content = originalText;
+ element._newContent = originalText;
+ flushAsynchronousOperations();
+ });
+
+ test('initial load', () => {
+ assert.equal(element.$.file.value, originalText);
+ assert.isTrue(element.$.save.hasAttribute('disabled'));
+ });
+
+ test('file modification and save, !ok response', done => {
+ const saveSpy = sandbox.spy(element, '_saveEdit');
+ saveFileStub.returns(Promise.resolve({ok: false}));
+ element._newContent = newText;
+ flushAsynchronousOperations();
+
+ assert.equal(element.$.file.value, newText);
+ assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+ MockInteractions.tap(element.$.save);
+ assert(saveSpy.called);
+ saveSpy.lastCall.returnValue.then(() => {
+ assert.isTrue(saveFileStub.called);
+ assert.deepEqual(saveFileStub.lastCall.args,
+ [mockParams.changeNum, mockParams.path, newText]);
+ assert.isFalse(navigateStub.called);
+ done();
+ });
+ });
+
+ test('file modification and save', done => {
+ const saveSpy = sandbox.spy(element, '_saveEdit');
+ saveFileStub.returns(Promise.resolve({ok: true}));
+ element._newContent = newText;
+ flushAsynchronousOperations();
+
+ assert.equal(element.$.file.value, newText);
+ assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+ MockInteractions.tap(element.$.save);
+ assert.isTrue(saveSpy.called);
+ saveSpy.lastCall.returnValue.then(() => {
+ assert.isTrue(saveFileStub.called);
+ assert.isTrue(navigateStub.called);
+ done();
+ });
+ });
+
+ test('file modification and cancel', () => {
+ const cancelSpy = sandbox.spy(element, '_handleCancelTap');
+ element._newContent = newText;
+ flushAsynchronousOperations();
+
+ assert.equal(element.$.file.value, newText);
+ assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+ MockInteractions.tap(element.$.cancel);
+ assert.isTrue(cancelSpy.called);
+ assert.isFalse(saveFileStub.called);
+ assert.isTrue(navigateStub.called);
+ });
+ });
+});
+</script>
\ No newline at end of file