Show loading errors inside inline diffs

When diffs encounter loading errors (such as 404s or 500s in the diff
request), the diff would formerly bubble up an event that showed an
error message that blocks the entire page. This works well in the diff
view because the singular diff is the main content of the page.

However, when expanding many files inline in the change view, if only
some of the diffs encounter loading errors, the error message need not
obstruct the rest of the page, which may contain diffs that load fine.

With this change, inline diffs show their own descriptive loading
errors, and won't hide the rest of the content. The structure of the
diff loading request is refactored so that errors can be caught and
handled cleanly in the promise chain and the overall async-loop that
renders many diffs in order will not be halted when one diff fails.

Bug: Issue 9590
Change-Id: I9d984d39dcec90119d05acd94afad938e5fb7c1e
This commit is contained in:
Wyatt Allen 2018-08-15 14:13:01 -07:00
parent 55c6c0e684
commit eb04344f67
6 changed files with 155 additions and 52 deletions

View File

@ -390,6 +390,7 @@ limitations under the License.
if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"> if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
<gr-diff <gr-diff
no-auto-render no-auto-render
show-load-failure
display-line="[[_displayLine]]" display-line="[[_displayLine]]"
inline-index=[[index]] inline-index=[[index]]
hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]" hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"

View File

@ -50,6 +50,9 @@ limitations under the License.
stub('gr-rest-api-interface', { stub('gr-rest-api-interface', {
getLoggedIn() { return Promise.resolve(false); }, getLoggedIn() { return Promise.resolve(false); },
getDiff() {
return Promise.resolve(mockDiffResponse.diffResponse);
},
}); });
const fixtureElems = fixture('basic'); const fixtureElems = fixture('basic');
@ -60,15 +63,12 @@ limitations under the License.
// Register the diff with the cursor. // Register the diff with the cursor.
cursorElement.push('diffs', diffElement); cursorElement.push('diffs', diffElement);
diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
diffElement.comments = {left: [], right: []}; diffElement.comments = {left: [], right: []};
diffElement.$.restAPI.getDiffPreferences().then(prefs => { diffElement.$.restAPI.getDiffPreferences().then(prefs => {
diffElement.prefs = prefs; diffElement.prefs = prefs;
}); });
sandbox.stub(diffElement, '_getDiff', () => {
return Promise.resolve(mockDiffResponse.diffResponse);
});
const setupDone = () => { const setupDone = () => {
cursorElement._updateStops(); cursorElement._updateStops();
cursorElement.moveToFirstChunk(); cursorElement.moveToFirstChunk();

View File

@ -192,15 +192,20 @@ limitations under the License.
font-size: var(--font-size, var(--font-size-normal)); font-size: var(--font-size, var(--font-size-normal));
padding: 0.5em 0 0.5em 4em; padding: 0.5em 0 0.5em 4em;
} }
#loadingError,
#sizeWarning { #sizeWarning {
display: none; display: none;
margin: 1em auto; margin: 1em auto;
max-width: 60em; max-width: 60em;
text-align: center; text-align: center;
} }
#loadingError {
color: var(--error-text-color);
}
#sizeWarning gr-button { #sizeWarning gr-button {
margin: 1em; margin: 1em;
} }
#loadingError.showError,
#sizeWarning.warn { #sizeWarning.warn {
display: block; display: block;
} }
@ -299,6 +304,9 @@ limitations under the License.
<div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]"> <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
[[_newlineWarning]] [[_newlineWarning]]
</div> </div>
<div id="loadingError" class$="[[_computeErrorClass(_showError)]]">
[[_errorMessage]]
</div>
<div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]"> <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
<p> <p>
Prevented render because "Whole file" is enabled and this diff is very Prevented render because "Whole file" is enabled and this diff is very

View File

@ -123,6 +123,14 @@
*/ */
lineOfInterest: Object, lineOfInterest: Object,
/**
* If the diff fails to load, show the failure message in the diff rather
* than bubbling the error up to the whole page. This is useful for when
* loading inline diffs because one diff failing need not mark the whole
* page with a failure.
*/
showLoadFailure: Boolean,
_loading: { _loading: {
type: Boolean, type: Boolean,
value: false, value: false,
@ -162,6 +170,12 @@
_showWarning: Boolean, _showWarning: Boolean,
_showError: {
type: Boolean,
value: false,
},
_errorMessage: String,
/** @type {?Object} */ /** @type {?Object} */
_blame: { _blame: {
type: Object, type: Object,
@ -224,16 +238,23 @@
this.clearBlame(); this.clearBlame();
this._safetyBypass = null; this._safetyBypass = null;
this._showWarning = false; this._showWarning = false;
this._showError = false;
this.clearDiffContent(); this.clearDiffContent();
const promises = []; const diffRequest = this._getDiff();
const assetRequest = diffRequest.then(diff => {
// If the diff is null, then it's failed to load.
if (!diff) { return null; }
promises.push(this._getDiff().then(diff => {
this._diff = diff;
return this._loadDiffAssets(); return this._loadDiffAssets();
})); });
return Promise.all(promises).then(() => { return Promise.all([diffRequest, assetRequest]).then(results => {
const diff = results[0];
if (!diff) {
return Promise.resolve();
}
if (this.prefs) { if (this.prefs) {
return this._renderDiffTable(); return this._renderDiffTable();
} }
@ -727,34 +748,59 @@
this.fire('server-error', {response}); this.fire('server-error', {response});
return; return;
} }
if (this.showLoadFailure) {
this._errorMessage = [
'Encountered error when loading the diff:',
response.status,
response.statusText,
].join(' ');
this._showError = true;
return;
}
this.fire('page-error', {response}); this.fire('page-error', {response});
}, },
/** @return {!Promise<!Object>} */ /** @return {!Promise<!Object>} */
_getDiff() { _getDiff() {
return this.$.restAPI.getDiff( // Wrap the diff request in a new promise so that the error handler
this.changeNum, // rejects the promise, allowing the error to be handled in the .catch.
this.patchRange.basePatchNum, const request = new Promise((resolve, reject) => {
this.patchRange.patchNum, this.$.restAPI.getDiff(
this.path, this.changeNum,
this._handleGetDiffError.bind(this)).then(diff => { this.patchRange.basePatchNum,
this.patchRange.patchNum,
this.path,
reject)
.then(resolve);
});
return request
.then(diff => {
this.filesWeblinks = this._getFilesWeblinks(diff);
this._diff = diff;
this._reportDiff(diff); this._reportDiff(diff);
if (!this.commitRange) {
this.filesWeblinks = {};
return diff;
}
this.filesWeblinks = {
meta_a: Gerrit.Nav.getFileWebLinks(
this.projectName, this.commitRange.baseCommit, this.path,
{weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
meta_b: Gerrit.Nav.getFileWebLinks(
this.projectName, this.commitRange.commit, this.path,
{weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
};
return diff; return diff;
})
.catch(e => {
this._handleGetDiffError(e);
return null;
}); });
}, },
_getFilesWeblinks(diff) {
if (!this.commitRange) { return {}; }
return {
meta_a: Gerrit.Nav.getFileWebLinks(
this.projectName, this.commitRange.baseCommit, this.path,
{weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
meta_b: Gerrit.Nav.getFileWebLinks(
this.projectName, this.commitRange.commit, this.path,
{weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
};
},
/** /**
* Report info about the diff response. * Report info about the diff response.
*/ */
@ -890,6 +936,11 @@
return showWarning ? 'warn' : ''; return showWarning ? 'warn' : '';
}, },
/** @return {string} */
_computeErrorClass(showError) {
return showError ? 'showError' : '';
},
/** /**
* @return {number|null} * @return {number|null}
*/ */

View File

@ -410,7 +410,6 @@ limitations under the License.
suite('image diffs', () => { suite('image diffs', () => {
let mockFile1; let mockFile1;
let mockFile2; let mockFile2;
const stubs = [];
setup(() => { setup(() => {
mockFile1 = { mockFile1 = {
body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' + body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
@ -445,18 +444,18 @@ limitations under the License.
}; };
const mockComments = {baseComments: [], comments: []}; const mockComments = {baseComments: [], comments: []};
stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo', sandbox.stub(element.$.restAPI, 'getCommitInfo')
() => Promise.resolve(mockCommit))); .returns(Promise.resolve(mockCommit));
stubs.push(sandbox.stub(element.$.restAPI, sandbox.stub(element.$.restAPI,
'getB64FileContents', 'getB64FileContents',
(changeId, patchNum, path, opt_parentIndex) => { (changeId, patchNum, path, opt_parentIndex) => {
return Promise.resolve(opt_parentIndex === 1 ? mockFile1 : return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
mockFile2); mockFile2);
})); });
stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments', sandbox.stub(element.$.restAPI, '_getDiffComments')
() => Promise.resolve(mockComments))); .returns(Promise.resolve(mockComments));
stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts', sandbox.stub(element.$.restAPI, 'getDiffDrafts')
() => Promise.resolve(mockComments))); .returns(Promise.resolve(mockComments));
element.patchRange = {basePatchNum: 'PARENT', patchNum: 1}; element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
element.comments = {left: [], right: []}; element.comments = {left: [], right: []};
@ -479,8 +478,8 @@ limitations under the License.
content: [{skip: 66}], content: [{skip: 66}],
binary: true, binary: true,
}; };
stubs.push(sandbox.stub(element, '_getDiff', sandbox.stub(element.$.restAPI, 'getDiff')
() => Promise.resolve(mockDiff))); .returns(Promise.resolve(mockDiff));
const rendered = () => { const rendered = () => {
// Recognizes that it should be an image diff. // Recognizes that it should be an image diff.
@ -559,8 +558,8 @@ limitations under the License.
content: [{skip: 66}], content: [{skip: 66}],
binary: true, binary: true,
}; };
stubs.push(sandbox.stub(element, '_getDiff', sandbox.stub(element.$.restAPI, 'getDiff')
() => Promise.resolve(mockDiff))); .returns(Promise.resolve(mockDiff));
const rendered = () => { const rendered = () => {
// Recognizes that it should be an image diff. // Recognizes that it should be an image diff.
@ -640,8 +639,8 @@ limitations under the License.
content: [{skip: 66}], content: [{skip: 66}],
binary: true, binary: true,
}; };
stubs.push(sandbox.stub(element, '_getDiff', sandbox.stub(element.$.restAPI, 'getDiff')
() => Promise.resolve(mockDiff))); .returns(Promise.resolve(mockDiff));
element.addEventListener('render', () => { element.addEventListener('render', () => {
// Recognizes that it should be an image diff. // Recognizes that it should be an image diff.
@ -679,8 +678,8 @@ limitations under the License.
content: [{skip: 66}], content: [{skip: 66}],
binary: true, binary: true,
}; };
stubs.push(sandbox.stub(element, '_getDiff', sandbox.stub(element.$.restAPI, 'getDiff')
() => Promise.resolve(mockDiff))); .returns(Promise.resolve(mockDiff));
element.addEventListener('render', () => { element.addEventListener('render', () => {
// Recognizes that it should be an image diff. // Recognizes that it should be an image diff.
@ -720,8 +719,8 @@ limitations under the License.
}; };
mockFile1.type = 'image/jpeg-evil'; mockFile1.type = 'image/jpeg-evil';
stubs.push(sandbox.stub(element, '_getDiff', sandbox.stub(element.$.restAPI, 'getDiff')
() => Promise.resolve(mockDiff))); .returns(Promise.resolve(mockDiff));
element.addEventListener('render', () => { element.addEventListener('render', () => {
// Recognizes that it should be an image diff. // Recognizes that it should be an image diff.
@ -793,6 +792,55 @@ limitations under the License.
element._getDiff().then(done); element._getDiff().then(done);
}); });
test('_getDiff resolves as null on error', () => {
const onErrStub = sandbox.stub(element, '_handleGetDiffError');
const error = {ok: false, status: 500};
sandbox.stub(element.$.restAPI, 'getDiff',
(changeNum, basePatchNum, patchNum, path, onErr) => {
onErr(error);
});
return element._getDiff().then(diff => {
assert.isNull(diff);
assert.isTrue(onErrStub.calledOnce);
});
});
suite('_handleGetDiffError', () => {
let serverErrorStub;
let pageErrorStub;
setup(() => {
serverErrorStub = sinon.stub();
element.addEventListener('server-error', serverErrorStub);
pageErrorStub = sinon.stub();
element.addEventListener('page-error', pageErrorStub);
});
test('page error on HTTP-409', () => {
element._handleGetDiffError({status: 409});
assert.isTrue(serverErrorStub.calledOnce);
assert.isFalse(pageErrorStub.called);
assert.isFalse(element._showError);
});
test('server error on non-HTTP-409', () => {
element._handleGetDiffError({status: 500});
assert.isFalse(serverErrorStub.called);
assert.isTrue(pageErrorStub.calledOnce);
assert.isFalse(element._showError);
});
test('error message if showLoadFailure', () => {
element.showLoadFailure = true;
element._handleGetDiffError({status: 500, statusText: 'Failure!'});
assert.isFalse(serverErrorStub.called);
assert.isFalse(pageErrorStub.called);
assert.isTrue(element._showError);
assert.equal(element._errorMessage,
'Encountered error when loading the diff: 500 Failure!');
});
});
suite('getCursorStops', () => { suite('getCursorStops', () => {
const setupDiff = function() { const setupDiff = function() {
const mock = document.createElement('mock-diff-response'); const mock = document.createElement('mock-diff-response');

View File

@ -61,7 +61,6 @@
* endpoint: string, * endpoint: string,
* patchNum: (string|number|null|undefined), * patchNum: (string|number|null|undefined),
* errFn: (function(?Response, string=)|null|undefined), * errFn: (function(?Response, string=)|null|undefined),
* cancelCondition: (function()|null|undefined),
* params: (Object|null|undefined), * params: (Object|null|undefined),
* fetchOptions: (Object|null|undefined), * fetchOptions: (Object|null|undefined),
* anonymizedEndpoint: (string|undefined), * anonymizedEndpoint: (string|undefined),
@ -2112,10 +2111,8 @@
* @param {number|string} patchNum * @param {number|string} patchNum
* @param {string} path * @param {string} path
* @param {function(?Response, string=)=} opt_errFn * @param {function(?Response, string=)=} opt_errFn
* @param {function()=} opt_cancelCondition
*/ */
getDiff(changeNum, basePatchNum, patchNum, path, getDiff(changeNum, basePatchNum, patchNum, path, opt_errFn) {
opt_errFn, opt_cancelCondition) {
const params = { const params = {
context: 'ALL', context: 'ALL',
intraline: null, intraline: null,
@ -2133,7 +2130,6 @@
endpoint, endpoint,
patchNum, patchNum,
errFn: opt_errFn, errFn: opt_errFn,
cancelCondition: opt_cancelCondition,
params, params,
anonymizedEndpoint: '/files/*/diff', anonymizedEndpoint: '/files/*/diff',
}); });
@ -2796,7 +2792,6 @@
return this._fetchJSON({ return this._fetchJSON({
url: url + req.endpoint, url: url + req.endpoint,
errFn: req.errFn, errFn: req.errFn,
cancelCondition: req.cancelCondition,
params: req.params, params: req.params,
fetchOptions: req.fetchOptions, fetchOptions: req.fetchOptions,
anonymizedUrl: anonymizedEndpoint ? anonymizedUrl: anonymizedEndpoint ?