import {html, LitElement, nothing} from "lit";
import {customElement} from "lit/decorators/custom-element.js";
import {property} from "lit/decorators/property.js";
import {state} from "lit/decorators/state.js";
import {classMap} from "lit/directives/class-map.js";
import {ifDefined} from "lit/directives/if-defined.js";
import shoelace from "../Styles/shoelace";
import styles from "./Et2File.styles";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {Et2FileItem} from "./Et2FileItem";
import {Resumable} from "../../Resumable/resumable";
import {repeat} from "lit/directives/repeat.js";
// ResumableFile not defined in a way we can use it
export interface FileInfo //extends ResumableFile
{
loading? : boolean;
accepted? : boolean;
warning? : string;
// ResumableFile
uniqueIdentifier : string;
file : File;
progress : Function;
abort : Function
}
/**
* @summary Displays a button to select files to upload
*
*
* @dependency sl-format-bytes
* @dependency sl-progress-bar
* @dependency sl-icon
*
* @slot - File name
* @slot image - The file's image (mimetype icon, status icon, etc)
* @slot close-button - Close button
* @event load - Emitted when file is loaded
*
* @csspart base - Component internal wrapper
*/
@customElement("et2-file")
export class Et2File extends Et2InputWidget(LitElement)
{
static get styles()
{
return [
shoelace,
super.styles,
styles
];
}
/** A string that defines the file types the file dropzone should accept. Defaults to all. */
@property({type: String, reflect: true}) accept = "";
/** An optional maximum size of a file that will be considered valid. */
@property({type: Number, attribute: "max-file-size"}) maxFileSize? : number;
/** The maximum amount of files that are allowed. */
@property({type: Number, attribute: "max-files"}) maxFiles : number;
/** Indicates if multiple files can be uploaded */
@property({type: Boolean, reflect: true}) multiple = false;
/** Draws the item in a loading state. */
@property({type: Boolean, reflect: true}) loading = false;
/** If true, no file list will be shown */
@property({type: Boolean, reflect: true, attribute: "no-file-list"}) noFileList = false;
/** Target element to show file list in instead */
@property({type: String}) fileListTarget : string;
@property({type: String}) display : "large" | "small" | "list" = "large";
/** Show the files inline instead of over the rest of the page */
@property({type: Boolean}) inline = false;
/** The button's image */
@property({type: String}) image : string = "paperclip";
/** Server path to receive uploaded file */
@property({type: String}) uploadTarget : string = "EGroupware\\Api\\Etemplate\\Widget\\File::ajax_upload";
@property({type: Object}) uploadOptions : {};
/** Files already uploaded */
@property({type: Array}) value : { [tempName : string] : FileInfo[] } = {};
@state() files : FileInfo[] = [];
protected resumable = null;
get fileInput() : HTMLInputElement { return this.shadowRoot?.querySelector("#file-input");}
get list() : HTMLElement
{
return this.fileListTarget ?
this.egw().window.document.querySelector(this.fileListTarget) ?? this.getRoot()?.getWidgetById(this.fileListTarget) :
this.shadowRoot?.querySelector("slot[name='list']");
}
get fileItemList() : Et2FileItem[]
{
return Array.from(this.list?.querySelectorAll("et2-file-item")) ?? [];
}
disconnectedCallback()
{
if(this.resumable)
{
this.resumable.cancel();
}
}
firstUpdated()
{
this.resumable = this.createResumable();
}
protected createResumable()
{
const resumable = new Resumable(this.resumableOptions);
resumable.assignBrowse(this.fileInput);
resumable.on('fileAdded', this.resumableFileAdded.bind(this));
resumable.on('fileProgress', this.resumableFileProgress.bind(this));
resumable.on('fileSuccess', this.resumableFileSuccess.bind(this));
resumable.on('fileError', this.resumableFileError.bind(this));
resumable.on('complete', this.resumableUploadComplete.bind(this));
return resumable;
}
protected get resumableOptions()
{
const options = {
target: this.egw().ajaxUrl(this.uploadTarget),
query: {
request_id: this.getInstanceManager().etemplate_exec_id,
widget_id: this.id,
},
chunkSize: 1024 * 1024,
// Checking for already uploaded chunks - resumable uploads
testChunks: true,
testTarget: this.egw().ajaxUrl("EGroupware\\Api\\Etemplate\\Widget\\File::ajax_test_chunk")
};
if(this.accept)
{
options["fileType"] = this.accept.split(",").map(f => f.trim())
}
if(this.maxFiles)
{
options["maxFiles"] = this.maxFiles;
}
if(this.maxFileSize)
{
options['maxFileSize'] = this.maxFileSize;
}
Object.assign(options, this.uploadOptions);
return options;
}
public findFileItem(file)
{
const fileInfo = this.files.find((i) => i.file.uniqueIdentifier == file.uniqueIdentifier);
file = fileInfo;
const fileIndex = this.files.indexOf(fileInfo) ?? null;
const fileItem : Et2FileItem = fileIndex !== -1 ? this.fileItemList[fileIndex] : null;
return fileItem;
}
private async resumableFileAdded(file : FileInfo, event)
{
file = {
accepted: true,
loading: true,
...file
}
this.files = this.multiple ? [...this.files, file] : [file];
this.requestUpdate();
if(!file.accepted)
{
return;
}
await this.updateComplete;
const fileItem = this.findFileItem(file);
if(!fileItem)
{
return;
}
fileItem.loading = true;
fileItem.requestUpdate("loading");
// Bind close => abort upload
fileItem.addEventListener("sl-hide", () =>
{
file.abort();
this.resumable.removeFile(file);
}, {once: true});
// Actually start uploading
await fileItem.updateComplete;
setTimeout(this.resumable.upload, 100);
}
private resumableFileProgress(file : FileInfo, event)
{
const fileItem = this.findFileItem(file);
if(fileItem && file.progress())
{
fileItem.progress = file.progress() * 100;
fileItem.requestUpdate("progress");
}
}
private resumableFileSuccess(file : FileInfo, jsonResponse)
{
const response = (JSON.parse(jsonResponse)['response'] ?? {}).find(i => i.type == "data")['data'] ?? {};
const fileItem = this.findFileItem(file);
file.loading = false;
fileItem.progress = 100;
fileItem.loading = false;
if(!response || response.length == 0)
{
file.warning = "No response";
fileItem.variant = "warning";
fileItem.innerHTML += "
" + file.warning;
}
else
{
Object.keys(response).forEach((tempName) =>
{
fileItem.variant = "success";
// Add file into value
this.value[tempName] = {
file: file.file,
src: fileItem.shadowRoot.querySelector("slot[name='image'] ").assignedElements()[0]?.src ?? "",
...response[tempName]
}
// Remove file from file input & resumable
this.resumable.removeFile(file);
this.removeFile(file);
})
}
fileItem.requestUpdate("loading");
fileItem.requestUpdate("progress");
fileItem.requestUpdate("variant");
}
private resumableFileError(file, message)
{
const fileItem = this.findFileItem(file);
fileItem.loading = false;
file.warning = message;
fileItem.error(message);
fileItem.requestUpdate("variant");
fileItem.requestUpdate("loading");
}
private resumableUploadComplete()
{
this.requestUpdate();
this.updateComplete.then(() =>
{
this.dispatchEvent(new CustomEvent("change", {detail: this.value, bubbles: true}));
})
}
addFile(file : File)
{
if(this.maxFiles && this.files.length >= this.maxFiles)
{
// TODO : Warn too many files
return;
}
const fileInfo : FileInfo = {
file,
};
/*
TODO
if(!hasValidFileType(file, this.mime))
{
fileInfo.accepted = false;
fileInfo.warning = this.egw().lang("File is of wrong type (%1 != %2)!", file.type, this.mime)
}
else if(!hasValidFileSize(file, this.maxFileSize))
{
fileInfo.accepted = false;
// TODO: Stop using et2_vfsSize
fileInfo.warning = this.egw().lang("File too large. Maximum %1", et2_vfsSize.prototype.human_size(this.maxFileSize));
}
else
*/
{
fileInfo.accepted = true;
}
this.files = this.multiple ? [...this.files, fileInfo] : [fileInfo];
this.requestUpdate("files");
if(fileInfo.accepted)
{
this.updateComplete.then(() =>
{
this.resumable.addFile(fileInfo.file)
})
}
}
removeFile(file : FileInfo)
{
const fileInfo = this.files.find((i) => i.uniqueIdentifier == file.uniqueIdentifier);
const fileIndex = this.files.indexOf(fileInfo) ?? null;
if(fileIndex != -1)
{
this.files.splice(fileIndex, 1);
}
this.requestUpdate("files");
}
handleFiles(fileList : FileList | null)
{
if(!fileList || fileList.length === 0)
{
return;
}
if(!this.multiple && fileList.length > 1)
{
// TODO : Warn too many files
return;
}
Object.values(fileList).forEach(file => this.addFile(file));
this.dispatchEvent(new CustomEvent("change", {bubbles: true, detail: this.files}));
}
handleBrowseFileClick()
{
if(this.disabled)
{
return;
}
this.fileInput.click();
}
handleFileInputChange()
{
this.loading = true;
this.requestUpdate("loading");
setTimeout(() =>
{
this.handleFiles(this.fileInput.files);
this.loading = false;
this.requestUpdate("loading");
});
}
handleFileRemove(info : FileInfo)
{
const index = this.files.indexOf(info);
if(index === -1)
{
return;
}
const fileInfo = this.files[index];
this.files.splice(index, 1);
this.requestUpdate();
this.updateComplete.then(() =>
{
this.dispatchEvent(new CustomEvent("change", {detail: this.files}));
})
}
handleFileListChange(list)
{
debugger;
for(const mutation of list)
{
if(mutation.type === "childList")
{
}
}
}
fileItemTemplate(fileInfo : FileInfo, index)
{
let icon = fileInfo.icon ?? (fileInfo.warning ? "exclamation-triangle" : undefined);
// Pull thumbnail from file if we can
let thumbnail;
if(!icon && fileInfo.file && fileInfo.file.type.startsWith("image/"))
{
thumbnail = URL.createObjectURL(fileInfo.file);
}
return html`