blob: d1d05b7424cd8bf74224cbc4c1c25b7b9275a20c [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/iron-input/iron-input';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-dropdown/gr-dropdown';
import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {ChangeInfo, RevisionPatchSetNum} from '../../../types/common';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {
AutocompleteQuery,
AutocompleteSuggestion,
GrAutocomplete,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {getAppContext} from '../../../services/app-context';
import {fireAlert, fireReload} from '../../../utils/event-util';
import {
assertIsDefined,
query as queryUtil,
queryAll,
} from '../../../utils/common-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html, css} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
import {IronInputElement} from '@polymer/iron-input/iron-input';
import {createEditUrl} from '../../../models/views/edit';
import {resolve} from '../../../models/dependency';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {whenVisible} from '../../../utils/dom-util';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@customElement('gr-edit-controls')
export class GrEditControls extends LitElement {
// private but used in test
@query('#newPathIronInput') newPathIronInput?: IronInputElement;
@query('#modal') modal?: HTMLDialogElement;
// private but used in test
@query('#openDialog') openDialog?: GrDialog;
// private but used in test
@query('#deleteDialog') deleteDialog?: GrDialog;
// private but used in test
@query('#renameDialog') renameDialog?: GrDialog;
// private but used in test
@query('#restoreDialog') restoreDialog?: GrDialog;
@property({type: Object})
change?: ChangeInfo;
@property({type: String})
patchNum?: RevisionPatchSetNum;
@property({type: Array})
hiddenActions: string[] = [GrEditConstants.Actions.RESTORE.id];
// private but used in test
@state() actions: GrEditAction[] = Object.values(GrEditConstants.Actions);
// private but used in test
@state() path = '';
// private but used in test
@state() newPath = '';
private readonly query: AutocompleteQuery = (input: string) =>
this.queryFiles(input);
private readonly restApiService = getAppContext().restApiService;
private readonly getNavigation = resolve(this, navigationToken);
static override get styles() {
return [
sharedStyles,
modalStyles,
css`
:host {
align-items: center;
display: flex;
justify-content: flex-end;
}
.invisible {
display: none;
}
gr-button {
margin-left: var(--spacing-l);
text-decoration: none;
}
gr-dialog {
width: 50em;
}
gr-dialog .main {
width: 100%;
}
gr-dialog .main > iron-input {
width: 100%;
}
input {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin: var(--spacing-m) 0;
padding: var(--spacing-s);
width: 100%;
box-sizing: content-box;
}
#fileUploadBrowse {
margin-left: 0;
}
#dragDropArea {
border: 2px dashed var(--border-color);
border-radius: var(--border-radius);
margin-top: var(--spacing-l);
padding: var(--spacing-xxl) var(--spacing-xxl);
text-align: center;
}
#dragDropArea > p {
font-weight: var(--font-weight-bold);
padding: var(--spacing-s);
}
@media screen and (max-width: 50em) {
gr-dialog {
width: 100vw;
}
}
`,
];
}
override render() {
return html`
${this.actions.map(action => this.renderAction(action))}
<dialog id="modal" tabindex="-1">
${this.renderOpenDialog()} ${this.renderDeleteDialog()}
${this.renderRenameDialog()} ${this.renderRestoreDialog()}
</dialog>
`;
}
private renderAction(action: GrEditAction) {
return html`
<gr-button
id=${action.id}
class=${this.computeIsInvisible(action.id)}
link=""
@click=${this.handleTap}
>${action.label}</gr-button
>
`;
}
private renderOpenDialog() {
return html`
<gr-dialog
id="openDialog"
class="invisible dialog"
?disabled=${!this.isValidPath(this.path)}
confirm-label="Confirm"
confirm-on-enter=""
@confirm=${this.handleOpenConfirm}
@cancel=${this.handleDialogCancel}
>
<div class="header" slot="header">
Add a new file or open an existing file
</div>
<div class="main" slot="main">
<gr-autocomplete
placeholder="Enter an existing or new full file path."
.query=${this.query}
.text=${this.path}
@text-changed=${this.handleTextChanged}
></gr-autocomplete>
<div
id="dragDropArea"
contenteditable="true"
@drop=${this.handleDragAndDropUpload}
@keypress=${this.handleKeyPress}
>
<p>Drag and drop a file here</p>
<p>or</p>
<p>
<iron-input>
<input
id="fileUploadInput"
type="file"
@change=${this.handleFileUploadChanged}
multiple
hidden
/>
</iron-input>
<label for="fileUploadInput">
<gr-button id="fileUploadBrowse">Browse</gr-button>
</label>
</p>
</div>
</div>
</gr-dialog>
`;
}
private renderDeleteDialog() {
return html`
<gr-dialog
id="deleteDialog"
class="invisible dialog"
?disabled=${!this.isValidPath(this.path)}
confirm-label="Delete"
confirm-on-enter=""
@confirm=${this.handleDeleteConfirm}
@cancel=${this.handleDialogCancel}
>
<div class="header" slot="header">Delete a file from the repo</div>
<div class="main" slot="main">
<gr-autocomplete
placeholder="Enter an existing full file path."
.query=${this.query}
.text=${this.path}
@text-changed=${this.handleTextChanged}
></gr-autocomplete>
</div>
</gr-dialog>
`;
}
private renderRenameDialog() {
return html`
<gr-dialog
id="renameDialog"
class="invisible dialog"
?disabled=${!this.isValidPath(this.path) ||
!this.isValidPath(this.newPath)}
confirm-label="Rename"
confirm-on-enter=""
@confirm=${this.handleRenameConfirm}
@cancel=${this.handleDialogCancel}
>
<div class="header" slot="header">Rename a file in the repo</div>
<div class="main" slot="main">
<gr-autocomplete
placeholder="Enter an existing full file path."
.query=${this.query}
.text=${this.path}
@text-changed=${this.handleTextChanged}
></gr-autocomplete>
<iron-input
id="newPathIronInput"
.bindValue=${this.newPath}
@bind-value-changed=${this.handleBindValueChangedNewPath}
>
<input id="newPathInput" placeholder="Enter the new path." />
</iron-input>
</div>
</gr-dialog>
`;
}
private renderRestoreDialog() {
return html`
<gr-dialog
id="restoreDialog"
class="invisible dialog"
confirm-label="Restore"
confirm-on-enter=""
@confirm=${this.handleRestoreConfirm}
@cancel=${this.handleDialogCancel}
>
<div class="header" slot="header">Restore this file?</div>
<div class="main" slot="main">
<iron-input
.bindValue=${this.path}
@bind-value-changed=${this.handleBindValueChangedPath}
>
<input ?disabled=${''} />
</iron-input>
</div>
</gr-dialog>
`;
}
private readonly handleTap = (e: Event) => {
e.preventDefault();
const target = e.target as Element;
const action = target.id;
switch (action) {
case GrEditConstants.Actions.OPEN.id:
this.openOpenDialog();
return;
case GrEditConstants.Actions.DELETE.id:
this.openDeleteDialog();
return;
case GrEditConstants.Actions.RENAME.id:
this.openRenameDialog();
return;
case GrEditConstants.Actions.RESTORE.id:
this.openRestoreDialog();
return;
}
};
openOpenDialog(path?: string) {
if (path) {
this.path = path;
}
assertIsDefined(this.openDialog, 'openDialog');
this.showDialog(this.openDialog);
}
openDeleteDialog(path?: string) {
if (path) {
this.path = path;
}
assertIsDefined(this.deleteDialog, 'deleteDialog');
this.showDialog(this.deleteDialog);
}
openRenameDialog(path?: string) {
if (path) {
this.path = path;
}
assertIsDefined(this.renameDialog, 'renameDialog');
this.showDialog(this.renameDialog);
}
openRestoreDialog(path?: string) {
assertIsDefined(this.restoreDialog, 'restoreDialog');
if (path) {
this.path = path;
}
this.showDialog(this.restoreDialog);
}
/**
* Given a path string, checks that it is a valid file path.
*
* private but used in test
*/
isValidPath(path: string) {
// Double negation needed for strict boolean return type.
return !!path.length && !path.endsWith('/');
}
/**
* Given a dom event, gets the dialog that lies along this event path.
*
* private but used in test
*/
getDialogFromEvent(e: Event): GrDialog | undefined {
return e.composedPath().find(element => {
if (!(element instanceof Element)) return false;
if (!element.classList) return false;
return element.classList.contains('dialog');
}) as GrDialog | undefined;
}
// private but used in test
showDialog(dialog: GrDialog) {
assertIsDefined(this.modal, 'modal');
// Some dialogs may not fire their on-close event when closed in certain
// ways (e.g. by clicking outside the dialog body). This call prevents
// multiple dialogs from being shown in the same modal.
this.hideAllDialogs();
this.modal.showModal();
whenVisible(this.modal, () => {
dialog.classList.toggle('invisible', false);
const autocomplete = queryUtil<GrAutocomplete>(dialog, 'gr-autocomplete');
if (autocomplete) {
autocomplete.focus();
}
});
}
// private but used in test
hideAllDialogs() {
const dialogs = queryAll<GrDialog>(this, '.dialog');
for (const dialog of dialogs) {
// We set the second param to false, because this function
// is called by showDialog which when you open either restore,
// delete or rename dialogs, it reseted the automatically
// set input.
this.closeDialog(dialog, false);
}
}
// private but used in test
closeDialog(dialog?: GrDialog, clearInputs = true) {
if (!dialog) return;
if (clearInputs) {
// Dialog may have autocompletes and plain inputs -- as these have
// different properties representing their bound text, it is easier to
// just make two separate queries.
dialog.querySelectorAll('gr-autocomplete').forEach(input => {
input.text = '';
});
dialog.querySelectorAll('iron-input').forEach(input => {
input.bindValue = '';
});
}
dialog.classList.toggle('invisible', true);
assertIsDefined(this.modal, 'modal');
this.modal.close();
}
private readonly handleDialogCancel = (e: Event) => {
this.closeDialog(this.getDialogFromEvent(e));
};
private readonly handleOpenConfirm = (e: Event) => {
if (!this.change || !this.path) {
fireAlert(this, 'You must enter a path.');
this.closeDialog(this.openDialog);
return;
}
assertIsDefined(this.patchNum, 'patchset number');
const url = createEditUrl({
changeNum: this.change._number,
repo: this.change.project,
path: this.path,
patchNum: this.patchNum,
});
this.getNavigation().setUrl(url);
this.closeDialog(this.getDialogFromEvent(e));
};
// private but used in test
handleUploadConfirm(path: string, fileData: string) {
if (!this.change || !path || !fileData) {
fireAlert(this, 'You must enter a path and data.');
this.closeDialog(this.openDialog);
return Promise.resolve();
}
return this.restApiService
.saveFileUploadChangeEdit(this.change._number, path, fileData)
.then(res => {
if (!res || !res.ok) {
return;
}
this.closeDialog(this.openDialog);
fireReload(this, true);
});
}
private readonly handleDeleteConfirm = (e: Event) => {
// Get the dialog before the api call as the event will change during bubbling
// which will make Polymer.dom(e).path an empty array in polymer 2
const dialog = this.getDialogFromEvent(e);
if (!this.change || !this.path) {
fireAlert(this, 'You must enter a path.');
this.closeDialog(dialog);
return;
}
this.restApiService
.deleteFileInChangeEdit(this.change._number, this.path)
.then(res => {
if (!res || !res.ok) {
return;
}
this.closeDialog(dialog);
fireReload(this);
});
};
private readonly handleRestoreConfirm = (e: Event) => {
const dialog = this.getDialogFromEvent(e);
if (!this.change || !this.path) {
fireAlert(this, 'You must enter a path.');
this.closeDialog(dialog);
return;
}
this.restApiService
.restoreFileInChangeEdit(this.change._number, this.path)
.then(res => {
if (!res || !res.ok) {
return;
}
this.closeDialog(dialog);
fireReload(this);
});
};
private readonly handleRenameConfirm = (e: Event) => {
const dialog = this.getDialogFromEvent(e);
if (!this.change || !this.path || !this.newPath) {
fireAlert(this, 'You must enter a old path and a new path.');
this.closeDialog(dialog);
return;
}
this.restApiService
.renameFileInChangeEdit(this.change._number, this.path, this.newPath)
.then(res => {
if (!res || !res.ok) {
return;
}
this.closeDialog(dialog);
fireReload(this, true);
});
};
private queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
assertIsDefined(this.change, 'this.change');
assertIsDefined(this.patchNum, 'this.patchNum');
return this.restApiService
.queryChangeFiles(
this.change._number,
this.patchNum,
input,
throwingErrorCallback
)
.then(res => {
if (!res)
throw new Error('Failed to retrieve files. Response not set.');
return res.map(file => {
return {name: file};
});
});
}
private computeIsInvisible(id: string) {
return this.hiddenActions.includes(id) ? 'invisible' : '';
}
private readonly handleDragAndDropUpload = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) return;
this.fileUpload(e.dataTransfer.files);
};
private readonly handleFileUploadChanged = (e: InputEvent) => {
if (!e.target) return;
if (!(e.target instanceof HTMLInputElement)) return;
const input = e.target;
if (!input.files) return;
this.fileUpload(input.files);
};
private fileUpload(files: FileList) {
for (const file of files) {
if (!file) continue;
let path = this.path;
if (!path) {
path = file.name;
}
const fr = new FileReader();
// TODO(TS): Do we need this line?
// fr.file = file;
fr.onload = (fileLoadEvent: ProgressEvent<FileReader>) => {
if (!fileLoadEvent) return;
const fileData = fileLoadEvent.target!.result;
if (typeof fileData !== 'string') return;
this.handleUploadConfirm(path, fileData);
};
fr.readAsDataURL(file);
}
}
private readonly handleKeyPress = (e: KeyboardEvent) => {
e.preventDefault();
e.stopImmediatePropagation();
};
private readonly handleTextChanged = (e: BindValueChangeEvent) => {
this.path = e.detail.value ?? '';
};
private readonly handleBindValueChangedNewPath = (
e: BindValueChangeEvent
) => {
this.newPath = e.detail.value ?? '';
};
private readonly handleBindValueChangedPath = (e: BindValueChangeEvent) => {
this.path = e.detail.value ?? '';
};
}
declare global {
interface HTMLElementTagNameMap {
'gr-edit-controls': GrEditControls;
}
}