/** @jsxImportSource react */ import { Widget, VDOM } from "../../ui/Widget"; import { Icon } from "../Icon"; import { Field, FieldConfig, FieldInstance } from "./Field"; import { Url } from "../../ui/app/Url"; import { Localization } from "../../ui/Localization"; import type { RenderingContext } from "../../ui/RenderingContext"; import type { Instance } from "../../ui/Instance"; import { isNonEmptyArray } from "../../util/isNonEmptyArray"; import { BooleanProp, StringProp } from "../../ui/Prop"; //TODO: Implement UploadStatus which will enable canceling export interface UploadButtonConfig extends FieldConfig { /** Set to `true` to allow multiple file selection. Default is `false`. */ multiple?: boolean; /** HTTP method used for upload. Default is `POST`. */ method?: string; /** Set to `true` to abort uploads when the component is destroyed. Default is `false`. */ abortOnDestroy?: boolean; /** Text displayed while upload is in progress. */ uploadInProgressText?: string; /** URL to upload files to. */ url?: StringProp; /** Text displayed on the button. */ text?: StringProp; /** Name or configuration of the icon to be displayed on the button. */ icon?: StringProp; /** File types accepted for upload. E.g. `image/*`, `.pdf`, etc. */ accept?: StringProp; /** Base CSS class to be applied to the button. Defaults to `uploadbutton`. */ baseClass?: string; /** Callback to resolve the upload URL for each file. */ onResolveUrl?: | string | ((file: File, instance: Instance) => string | Promise); /** Callback invoked before upload starts. Return `false` to cancel the upload. */ onUploadStarting?: | string | (( xhr: XMLHttpRequest, instance: Instance, file: File, formData: FormData ) => boolean | Promise); /** Callback invoked when upload completes successfully. */ onUploadComplete?: | string | (( xhr: XMLHttpRequest, instance: Instance, file: File, formData: FormData ) => void); /** Callback invoked to report upload progress. */ onUploadProgress?: | string | (( event: ProgressEvent, instance: Instance, file: File, formData: FormData ) => void); /** Callback invoked when an upload error occurs. */ onUploadError?: | string | (( error: unknown, instance: Instance, file: File, formData: FormData ) => void); } export class UploadButton extends Field { declare public multiple: boolean; declare public method: string; declare public abortOnDestroy: boolean; declare public uploadInProgressText: string; declare public onResolveUrl?: | string | ((file: File, instance: Instance) => string | Promise); declare public onUploadStarting?: | string | (( xhr: XMLHttpRequest, instance: Instance, file: File, formData: FormData ) => boolean | Promise); declare public onUploadComplete?: | string | (( xhr: XMLHttpRequest, instance: Instance, file: File, formData: FormData ) => void); declare public onUploadProgress?: | string | (( event: ProgressEvent, instance: Instance, file: File, formData: FormData ) => void); declare public onUploadError?: | string | (( error: unknown, instance: Instance, file: File, formData: FormData ) => void); constructor(config?: UploadButtonConfig) { super(config); } declareData(...args: Record[]): void { super.declareData( { disabled: undefined, enabled: undefined, text: undefined, url: undefined, icon: undefined, accept: undefined, }, ...args ); } renderInput( context: RenderingContext, instance: FieldInstance, key: string ): React.ReactNode { let { data } = instance; return ( {data.text || this.renderChildren(context, instance)} ); } } UploadButton.prototype.baseClass = "uploadbutton"; UploadButton.prototype.multiple = false; UploadButton.prototype.method = "POST"; UploadButton.prototype.abortOnDestroy = false; UploadButton.prototype.uploadInProgressText = "Upload is in progress."; Localization.registerPrototype("cx/widgets/UploadButton", UploadButton); interface UploadButtonComponentProps { instance: FieldInstance; children?: React.ReactNode; } interface UploadButtonComponentState { progress: number; uploadKey: number; } interface Upload { progress: number; size: number; file: File; xhr: XMLHttpRequest; } class UploadButtonComponent extends VDOM.Component< UploadButtonComponentProps, UploadButtonComponentState > { uploads: Record; el?: HTMLDivElement; constructor(props: UploadButtonComponentProps) { super(props); this.uploads = {}; this.state = { progress: 100, uploadKey: 0, }; } render(): React.ReactNode { let { instance, children } = this.props; let { widget, data } = instance; let { CSS, baseClass } = widget; let icon; let className = data.classNames; if (data.icon) { icon = Icon.render(data.icon, { className: CSS.element(baseClass, "icon"), }); className = CSS.expand( className, CSS.state("icon"), !isNonEmptyArray(children) && CSS.state("empty") ); } return (
{ this.el = el || undefined; }} className={className} style={data.style} >
{icon} {children} {!data.disabled && ( )}
); } onFileSelected(e: React.ChangeEvent): void { let files = e.target.files; if (files) { for (let i = 0; i < files.length; i++) this.uploadFile(files[i]); } } componentDidMount(): void { if (this.props.instance.data.autoFocus) this.el!.focus(); } componentWillUnmount(): void { if (this.props.instance.widget.abortOnDestroy) { for (let key in this.uploads) { let upload = this.uploads[key]; upload.xhr.abort(); } } } uploadFile(file: File): void { let { instance } = this.props; let { data, widget } = instance; if (widget.onResolveUrl) { Promise.resolve(instance.invoke("onResolveUrl", file, instance)).then( (url) => { this.doUpload(file, url); } ); } else { this.doUpload(file, data.url); } } doUpload(file: File, url: string): void { let { instance } = this.props; let { widget } = instance; if (!url) throw new Error("Upload URL not set."); let xhr = new XMLHttpRequest(); xhr.open(widget.method, Url.resolve(url)); let formData = new FormData(); formData.append("file", file); let key = this.state.uploadKey; this.setState({ uploadKey: key + 1, }); let startingPromise = widget.onUploadStarting ? instance.invoke("onUploadStarting", xhr, instance, file, formData) : true; Promise.resolve(startingPromise) .then((result) => { if (result === false) return; let upload = (this.uploads[key] = { progress: 0, size: file.size || 1, file: file, xhr: xhr, }); xhr.onload = () => { delete this.uploads[key]; if (widget.onUploadComplete) instance.invoke("onUploadComplete", xhr, instance, file, formData); this.reportProgress(); }; xhr.onerror = (e) => { delete this.uploads[key]; if (widget.onUploadError) instance.invoke("onUploadError", e, instance, file, formData); this.reportProgress(); }; xhr.upload.onprogress = (event) => { if (event.lengthComputable) { upload.progress = event.loaded / event.total; this.reportProgress(); if (widget.onUploadProgress) instance.invoke( "onUploadProgress", event, instance, file, formData ); } }; xhr.send(formData); }) .catch((err) => { if (widget.onUploadError) instance.invoke("onUploadError", err, instance, file, formData); else console.error("Unhandled upload error.", err); }); this.reportProgress(); } reportProgress(): void { let totalSize = 0; let uploaded = 0; for (let key in this.uploads) { let upload = this.uploads[key]; totalSize += upload.size; uploaded += upload.size * upload.progress; } let progress = 100 * (totalSize ? uploaded / totalSize : 1); this.props.instance.setState({ inputError: progress == 100 ? false : this.props.instance.widget.uploadInProgressText, }); this.setState({ progress: Math.max(0.001, Math.floor(progress)), }); } } Widget.alias("upload-button", UploadButton);