blob: 878c5e9d8a9bcb03a946815a9e7faa683bc2ef2b [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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 {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {computeTruncatedPath} from '../../../utils/path-list-util';
import {
EditPreferencesInfo,
Base64FileContent,
RevisionPatchSetNum,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../types/types';
import {HttpMethod, NotifyType} from '../../../constants/constants';
import {
fireAlert,
fireTitleChange,
fireReload,
} 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 {Modifier} from '../../../utils/dom-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css, nothing} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {changeModelToken} from '../../../models/change/change-model';
import {ShortcutController} from '../../lit/shortcut-controller';
import {
ChangeChildView,
changeViewModelToken,
ChangeViewState,
createChangeUrl,
} from '../../../models/views/change';
import {userModelToken} from '../../../models/user/user-model';
import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
import {isDarkTheme} from '../../../utils/theme-util';
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
*/
@state() viewState?: ChangeViewState;
// private but used in test
@state() change?: ParsedChangeInfo;
// 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;
// private but used in test
@state() latestPatchsetNumber?: RevisionPatchSetNum;
@state() private darkMode = false;
private readonly restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
private readonly getStorage = resolve(this, storageServiceToken);
private readonly getUserModel = resolve(this, userModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getViewModel = resolve(this, changeViewModelToken);
private readonly getNavigation = resolve(this, navigationToken);
private readonly shortcuts = new ShortcutController(this);
// Tests use this so needs to be non private
storeTask?: DelayedTask;
constructor() {
super();
this.addEventListener('content-change', e => {
this.handleContentChange(e as CustomEvent<{value: string}>);
});
subscribe(
this,
() => this.getChangeModel().change$,
x => (this.change = x)
);
subscribe(
this,
() => this.getUserModel().editPreferences$,
editPreferences => (this.editPrefs = editPreferences)
);
subscribe(
this,
() => this.getViewModel().state$,
state => {
// TODO: Add a setter for `viewState` instead of relying on the
// `viewStateChanged()` call here.
this.viewState = state;
this.viewStateChanged();
}
);
subscribe(
this,
() => this.getChangeModel().latestPatchNumWithEdit$,
x => (this.latestPatchsetNumber = x)
);
subscribe(
this,
() => this.getUserModel().preferenceTheme$,
theme => {
this.darkMode = isDarkTheme(theme);
}
);
this.shortcuts.addLocal({key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
this.handleSaveShortcut()
);
this.shortcuts.addLocal({key: 's', modifiers: [Modifier.META_KEY]}, () =>
this.handleSaveShortcut()
);
}
override connectedCallback() {
super.connectedCallback();
}
override disconnectedCallback() {
this.storeTask?.flush();
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;
}
.warning {
color: var(--error-text-color);
}
`,
];
}
override render() {
if (this.viewState?.childView !== ChangeChildView.EDIT) return nothing;
return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
}
private renderHeader() {
return html`
<div class="stickyHeader">
<header>
<span class="controlGroup">
<span>Edit mode</span>
${this.renderEditingOldPatchsetWarning()}
<span class="separator"></span>
<gr-editable-label
labelText="File path"
.value=${this.viewState?.editView?.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 renderEditingOldPatchsetWarning() {
const patchset = this.viewState?.patchNum;
if (patchset === this.latestPatchsetNumber) return nothing;
return html`<span class="warning">&nbsp;(Old Patchset)</span>`;
}
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.viewState?.editView?.lineNum}
></gr-endpoint-param>
<gr-endpoint-param
name="darkMode"
.value=${this.darkMode}
></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('change')) {
this.navigateToChangeIfEdit();
}
if (changedProperties.has('change') || changedProperties.has('type')) {
this.navigateToChangeIfEditType();
}
}
get storageKey() {
return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.editView?.path}`;
}
// private but used in test
viewStateChanged() {
if (this.viewState?.childView !== ChangeChildView.EDIT) return;
// 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.viewState) return;
const title = `Editing ${computeTruncatedPath(
this.viewState.editView?.path
)}`;
fireTitleChange(this, title);
});
const promises = [];
promises.push(this.getFileData());
return Promise.all(promises);
}
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.'
);
this.getNavigation().setUrl(createChangeUrl({change: 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.');
this.getNavigation().setUrl(createChangeUrl({change: this.change}));
}
// private but used in test
async handlePathChanged(e: CustomEvent<string>): Promise<void> {
const changeNum = this.viewState?.changeNum;
const currentPath = this.viewState?.editView?.path;
assertIsDefined(changeNum, 'change number');
assertIsDefined(currentPath, 'path');
const newPath = e.detail;
if (newPath === currentPath) return;
const res = await this.restApiService.renameFileInChangeEdit(
changeNum,
currentPath,
newPath
);
if (!res?.ok) return;
this.successfulSave = true;
this.viewEditInChangeView();
}
// private but used in test
viewEditInChangeView() {
if (!this.change) return;
this.getNavigation().setUrl(
createChangeUrl({change: this.change, edit: true, forceReload: true})
);
}
// private but used in test
getFileData() {
const changeNum = this.viewState?.changeNum;
const patchNum = this.viewState?.patchNum;
const path = this.viewState?.editView?.path;
assertIsDefined(changeNum, 'change number');
assertIsDefined(patchNum, 'patchset number');
assertIsDefined(path, 'path');
const storedContent = this.getStorage().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() {
const changeNum = this.viewState?.changeNum;
const path = this.viewState?.editView?.path;
assertIsDefined(changeNum, 'change number');
assertIsDefined(path, 'path');
this.saving = true;
this.showAlert(SAVING_MESSAGE);
this.getStorage().eraseEditableContentItem(this.storageKey);
if (!this.newContent)
return Promise.reject(new Error('new content undefined'));
return this.restApiService
.saveChangeEdit(changeNum, 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 = () => {
const changeNum = this.viewState?.changeNum;
assertIsDefined(changeNum, 'change number');
this.saveEdit().then(() => {
const handleError: ErrorCallback = response => {
this.showAlert(PUBLISH_FAILED_MSG);
this.reporting.error('/edit:publish', 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');
// TODO: `forceReload: true` does not seem to work as expected: The patchset is not
// updated. Thus we are also calling `fireReload()` here. That can probably be
// cleaned up once the change-view was migrated to fully relying on the change model.
fireReload(this);
this.getNavigation().setUrl(
createChangeUrl({change: 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.getStorage().setEditableContentItem(this.storageKey, content);
} else {
this.getStorage().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;
}
}