blob: dc8e7a6d9f1519969e42e3dec7189bceb2b75cf7 [file] [log] [blame]
/**
* @license
* 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.
*/
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-editable-label/gr-editable-label';
import '../gr-default-editor/gr-default-editor';
import {
GerritNav,
GenerateUrlEditViewParameters,
} from '../../core/gr-navigation/gr-navigation';
import {computeTruncatedPath} from '../../../utils/path-list-util';
import {
PatchSetNum,
EditPreferencesInfo,
Base64FileContent,
NumericChangeId,
EditPatchSetNum,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../types/types';
import {HttpMethod, NotifyType} from '../../../constants/constants';
import {fireAlert, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
import {assertIsDefined} from '../../../utils/common-util';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {changeIsMerged, changeIsAbandoned} from '../../../utils/change-util';
import {addShortcut, Modifier} from '../../../utils/dom-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {subscribe} from '../../lit/subscription-controller';
const RESTORED_MESSAGE = 'Content restored from a previous edit.';
const SAVING_MESSAGE = 'Saving changes...';
const SAVED_MESSAGE = 'All changes saved';
const SAVE_FAILED_MSG = 'Failed to save changes';
const PUBLISHING_EDIT_MSG = 'Publishing edit...';
const PUBLISH_FAILED_MSG = 'Failed to publish edit';
const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
@customElement('gr-editor-view')
export class GrEditorView extends LitElement {
/**
* Fired when the title of the page should change.
*
* @event title-change
*/
/**
* Fired to notify the user of
*
* @event show-alert
*/
@property({type: Object})
params?: GenerateUrlEditViewParameters;
// private but used in test
@state() change?: ParsedChangeInfo;
// private but used in test
@state() changeNum?: NumericChangeId;
// private but used in test
@state() patchNum?: PatchSetNum;
// private but used in test
@state() path?: string;
// private but used in test
@state() type?: string;
// private but used in test
@state() content?: string;
// private but used in test
@state() newContent = '';
// private but used in test
@state() saving = false;
// private but used in test
@state() successfulSave = false;
@state() private editPrefs?: EditPreferencesInfo;
@state() private lineNum?: number;
private readonly restApiService = getAppContext().restApiService;
private readonly storage = getAppContext().storageService;
private readonly reporting = getAppContext().reportingService;
private readonly userModel = getAppContext().userModel;
// Tests use this so needs to be non private
storeTask?: DelayedTask;
/** Called in disconnectedCallback. */
private cleanups: (() => void)[] = [];
constructor() {
super();
this.addEventListener('content-change', e => {
this.handleContentChange(e as CustomEvent<{value: string}>);
});
}
override connectedCallback() {
super.connectedCallback();
subscribe(this, this.userModel.editPreferences$, editPreferences => {
this.editPrefs = editPreferences;
});
this.cleanups.push(
addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
this.handleSaveShortcut()
)
);
this.cleanups.push(
addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, () =>
this.handleSaveShortcut()
)
);
}
override disconnectedCallback() {
this.storeTask?.cancel();
for (const cleanup of this.cleanups) cleanup();
this.cleanups = [];
super.disconnectedCallback();
}
static override get styles() {
return [
sharedStyles,
css`
:host {
background-color: var(--view-background-color);
}
.stickyHeader {
background-color: var(--edit-mode-background-color);
border-bottom: 1px var(--border-color) solid;
position: sticky;
top: 0;
z-index: 1;
}
header {
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: var(--spacing-m) var(--spacing-l);
}
header gr-editable-label {
font-family: var(--header-font-family);
font-size: var(--font-size-h3);
font-weight: var(--font-weight-h3);
line-height: var(--line-height-h3);
}
header gr-editable-label::part(label) {
text-overflow: initial;
white-space: initial;
word-break: break-all;
}
header gr-editable-label::part(input-container) {
margin-top: var(--spacing-l);
}
.textareaWrapper {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin: var(--spacing-l);
}
.textareaWrapper .editButtons {
display: none;
}
.controlGroup {
align-items: center;
display: flex;
font-family: var(--header-font-family);
font-size: var(--font-size-h3);
font-weight: var(--font-weight-h3);
line-height: var(--line-height-h3);
}
.rightControls {
justify-content: flex-end;
}
`,
];
}
override render() {
return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
}
private renderHeader() {
return html`
<div class="stickyHeader">
<header>
<span class="controlGroup">
<span>Edit mode</span>
<span class="separator"></span>
<gr-editable-label
labelText="File path"
.value=${this.path}
placeholder="File path..."
@changed=${this.handlePathChanged}
></gr-editable-label>
</span>
<span class="controlGroup rightControls">
<gr-button id="close" link="" @click=${this.handleCloseTap}
>Cancel</gr-button
>
<gr-button
id="save"
?disabled=${this.computeSaveDisabled()}
primary=""
link=""
title="Save and Close the file"
@click=${this.handleSaveTap}
>Save</gr-button
>
<gr-button
id="publish"
link=""
primary=""
title="Publish your edit. A new patchset will be created."
@click=${this.handlePublishTap}
?disabled=${this.computeSaveDisabled()}
>Save & Publish</gr-button
>
</span>
</header>
</div>
`;
}
private renderEndpoint() {
return html`
<div class="textareaWrapper">
<gr-endpoint-decorator id="editorEndpoint" name="editor">
<gr-endpoint-param
name="fileContent"
.value=${this.newContent}
></gr-endpoint-param>
<gr-endpoint-param
name="prefs"
.value=${this.editPrefs}
></gr-endpoint-param>
<gr-endpoint-param
name="fileType"
.value=${this.type}
></gr-endpoint-param>
<gr-endpoint-param
name="lineNum"
.value=${this.lineNum}
></gr-endpoint-param>
<gr-default-editor
id="file"
.fileContent=${this.newContent}
></gr-default-editor>
</gr-endpoint-decorator>
</div>
`;
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('params')) {
this.paramsChanged();
}
if (changedProperties.has('change')) {
this.navigateToChangeIfEdit();
}
if (changedProperties.has('change') || changedProperties.has('type')) {
this.navigateToChangeIfEditType();
}
}
get storageKey() {
return `c${this.changeNum}_ps${this.patchNum}_${this.path}`;
}
// private but used in test
paramsChanged() {
if (!this.params) return;
if (this.params.view !== GerritNav.View.EDIT) {
return;
}
this.changeNum = this.params.changeNum;
this.path = this.params.path;
this.patchNum = this.params.patchNum || (EditPatchSetNum as PatchSetNum);
this.lineNum =
typeof this.params.lineNum === 'string'
? Number(this.params.lineNum)
: this.params.lineNum;
// 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.
setTimeout(() => {
if (!this.params) return;
const title = `Editing ${computeTruncatedPath(this.params.path)}`;
fireTitleChange(this, title);
});
const promises = [];
promises.push(this.getChangeDetail(this.changeNum));
promises.push(this.getFileData(this.changeNum, this.path, this.patchNum));
return Promise.all(promises);
}
private async getChangeDetail(changeNum: NumericChangeId) {
this.change = await this.restApiService.getChangeDetail(changeNum);
}
private navigateToChangeIfEdit() {
if (!this.change) return;
if (!changeIsMerged(this.change) && !changeIsAbandoned(this.change)) return;
fireAlert(
this,
'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
);
GerritNav.navigateToChange(this.change);
}
private navigateToChangeIfEditType() {
if (!this.change || !this.type || !this.type.startsWith('image/')) return;
// Prevent editing binary files
fireAlert(this, 'You cannot edit binary files within the inline editor.');
GerritNav.navigateToChange(this.change);
}
// private but used in test
async handlePathChanged(e: CustomEvent<string>): Promise<void> {
// TODO(TS) could be cleaned up, it was added for type requirements
if (this.changeNum === undefined || !this.path) {
throw new Error('changeNum or path undefined');
}
const path = e.detail;
if (path === this.path) return;
const res = await this.restApiService.renameFileInChangeEdit(
this.changeNum,
this.path,
path
);
if (!res?.ok) return;
this.successfulSave = true;
this.viewEditInChangeView();
}
// private but used in test
viewEditInChangeView() {
if (this.change)
GerritNav.navigateToChange(this.change, {
isEdit: true,
forceReload: true,
});
}
// private but used in test
getFileData(
changeNum: NumericChangeId,
path: string,
patchNum?: PatchSetNum
) {
if (patchNum === undefined) {
return Promise.reject(new Error('patchNum undefined'));
}
const storedContent = this.storage.getEditableContentItem(this.storageKey);
return this.restApiService
.getFileContent(changeNum, path, patchNum)
.then(res => {
const content = (res && (res as Base64FileContent).content) || '';
if (
storedContent &&
storedContent.message &&
storedContent.message !== content
) {
fireAlert(this, RESTORED_MESSAGE);
this.newContent = storedContent.message;
} else {
this.newContent = content;
}
this.content = content;
// A non-ok response may result if the file does not yet exist.
// The `type` field of the response is only valid when the file
// already exists.
if (res && res.ok && res.type) {
this.type = res.type;
} else {
this.type = '';
}
});
}
// private but used in test
saveEdit() {
if (this.changeNum === undefined || !this.path) {
return Promise.reject(new Error('changeNum or path undefined'));
}
this.saving = true;
this.showAlert(SAVING_MESSAGE);
this.storage.eraseEditableContentItem(this.storageKey);
if (!this.newContent)
return Promise.reject(new Error('new content undefined'));
return this.restApiService
.saveChangeEdit(this.changeNum, this.path, this.newContent)
.then(res => {
this.saving = false;
this.showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
if (!res.ok) {
return res;
}
this.content = this.newContent;
this.successfulSave = true;
return res;
});
}
// private but used in test
showAlert(message: string) {
fireAlert(this, message);
}
computeSaveDisabled() {
if ([this.content, this.newContent, this.saving].includes(undefined)) {
return true;
}
if (this.saving) {
return true;
}
return this.content === this.newContent;
}
// private but used in test
handleCloseTap = () => {
// TODO(kaspern): Add a confirm dialog if there are unsaved changes.
this.viewEditInChangeView();
};
private handleSaveTap = () => {
this.saveEdit().then(res => {
if (res.ok) this.viewEditInChangeView();
});
};
private handlePublishTap = () => {
assertIsDefined(this.changeNum, 'changeNum');
const changeNum = this.changeNum;
this.saveEdit().then(() => {
const handleError: ErrorCallback = response => {
this.showAlert(PUBLISH_FAILED_MSG);
this.reporting.error(new Error(response?.statusText));
};
this.showAlert(PUBLISHING_EDIT_MSG);
this.restApiService
.executeChangeAction(
changeNum,
HttpMethod.POST,
'/edit:publish',
undefined,
{notify: NotifyType.NONE},
handleError
)
.then(() => {
assertIsDefined(this.change, 'change');
GerritNav.navigateToChange(this.change, {forceReload: true});
});
});
};
private handleContentChange(e: CustomEvent<{value: string}>) {
this.storeTask = debounce(
this.storeTask,
() => {
const content = e.detail.value;
if (content) {
this.newContent = e.detail.value;
this.storage.setEditableContentItem(this.storageKey, content);
} else {
this.storage.eraseEditableContentItem(this.storageKey);
}
},
STORAGE_DEBOUNCE_INTERVAL_MS
);
}
// private but used in test
handleSaveShortcut() {
if (!this.computeSaveDisabled()) {
this.saveEdit();
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-editor-view': GrEditorView;
}
}