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