Merge "Introduce gr-editor-view"

This commit is contained in:
Kasper Nilsson
2017-10-02 12:43:28 +00:00
committed by Gerrit Code Review
7 changed files with 450 additions and 8 deletions

View File

@@ -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>

View File

@@ -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();
},
});
})();

View File

@@ -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>

View File

@@ -46,6 +46,7 @@ limitations under the License.
<link rel="import" href="./core/gr-reporting/gr-reporting.html">
<link rel="import" href="./core/gr-router/gr-router.html">
<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
@@ -151,11 +152,15 @@ limitations under the License.
view-state="{{_viewState.changeView}}"
back-page="[[_lastSearchPage]]"></gr-change-view>
</template>
<template is="dom-if" if="[[_showDiffView]]" restamp="true">
<gr-diff-view
params="[[params]]"
change-view-state="{{_viewState.changeView}}"></gr-diff-view>
<template is="dom-if" if="[[_showEditorView]]" restamp="true">
<gr-editor-view
params="[[params]]"></gr-editor-view>
</template>
<template is="dom-if" if="[[_showDiffView]]" restamp="true">
<gr-diff-view
params="[[params]]"
change-view-state="{{_viewState.changeView}}"></gr-diff-view>
</template>
<template is="dom-if" if="[[_showSettingsView]]" restamp="true">
<gr-settings-view
params="[[params]]"

View File

@@ -56,6 +56,7 @@
_showSettingsView: Boolean,
_showAdminView: Boolean,
_showCLAView: Boolean,
_showEditorView: Boolean,
/** @type {?} */
_viewState: Object,
/** @type {?} */
@@ -139,6 +140,7 @@
this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN);
this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
if (this.params.justRegistered) {
this.$.registration.open();
}

View File

@@ -1249,9 +1249,18 @@
.then(response => this.getResponseObject(response));
},
getFileInChangeEdit(changeNum, path) {
/**
* Gets a file in a change edit.
* @param {number|string} changeNum
* @param {string} path
* @param {boolean=} opt_base If specified, file contents come from change
* edit's base patchset.
*/
getFileInChangeEdit(changeNum, path, opt_base) {
const e = '/edit/' + encodeURIComponent(path);
return this.getChangeURLAndSend(changeNum, 'GET', null, e);
let payload = null;
if (opt_base) { payload = {base: true}; }
return this.getChangeURLAndSend(changeNum, 'GET', null, e, payload);
},
rebaseChangeEdit(changeNum) {
@@ -1279,7 +1288,8 @@
saveChangeEdit(changeNum, path, contents) {
const e = '/edit/' + encodeURIComponent(path);
return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents);
return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents, null,
null, 'text/plain');
},
// Deprecated, prefer to use putChangeCommitMessage instead.
@@ -1845,7 +1855,7 @@
* @param {?string} endpoint gets passed as null.
* @param {?Object|number|string=} opt_payload gets passed as null, string,
* Object, or number.
* @param {function(?Response, string=)=} opt_errFn
* @param {?function(?Response, string=)=} opt_errFn
* @param {?=} opt_ctx
* @param {?=} opt_contentType
* @return {!Promise<!Object>}

View File

@@ -102,6 +102,7 @@ limitations under the License.
'diff/gr-selection-action-box/gr-selection-action-box_test.html',
'diff/gr-syntax-layer/gr-syntax-layer_test.html',
'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
'edit/gr-editor-view/gr-editor-view_test.html',
'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
'plugins/gr-event-helper/gr-event-helper_test.html',