Last active
January 17, 2022 15:01
-
-
Save IlCallo/96b0ca99d7400e5a795e8b918dbc2982 to your computer and use it in GitHub Desktop.
Axios uploader + TypeScript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import axios, { | |
AxiosError, | |
AxiosRequestConfig, | |
AxiosResponse, | |
Method, | |
} from 'axios'; | |
import { | |
QUploaderFactoryFn, | |
QUploaderFactoryObject, | |
ValueOrFunction, | |
} from 'quasar'; | |
import { | |
computed, | |
ExtractPropTypes, | |
PropType, | |
ref, | |
Ref, | |
SetupContext, | |
} from 'vue'; | |
function getFn<T = unknown | undefined, Arg = unknown>( | |
prop: ((...arg: Arg[]) => T) | T | |
): (...arg: Arg[]) => T { | |
return prop instanceof Function ? prop : () => prop; | |
} | |
/* | |
Wrap FileReader to make it Promise-based | |
*/ | |
function readFile(file: File) { | |
return new Promise<string | ArrayBuffer | null>((resolve, reject) => { | |
const reader = new FileReader(); | |
reader.onload = () => resolve(reader.result); | |
reader.onerror = reject; | |
reader.readAsDataURL(file); | |
}); | |
} | |
const _props = { | |
url: [Function, String] as PropType<Required<QUploaderFactoryObject['url']>>, | |
method: { | |
type: [Function, String] as PropType< | |
Required<QUploaderFactoryObject['method']> | |
>, | |
default: 'POST', | |
}, | |
fieldName: { | |
type: [Function, String] as PropType< | |
Required<QUploaderFactoryObject['fieldName']> | |
>, | |
default: () => { | |
return (file: File) => file.name; | |
}, | |
}, | |
headers: [Function, Array] as PropType< | |
Required<QUploaderFactoryObject['headers']> | |
>, | |
formFields: [Function, Array] as PropType< | |
Required<QUploaderFactoryObject['formFields']> | |
>, | |
withCredentials: [Function, Boolean] as PropType< | |
Required<QUploaderFactoryObject['withCredentials']> | |
>, | |
sendRaw: [Function, Boolean] as PropType< | |
Required<QUploaderFactoryObject['sendRaw']> | |
>, | |
batch: [Function, Boolean] as PropType<ValueOrFunction<boolean, File[]>>, | |
factory: Function as PropType<QUploaderFactoryFn>, | |
}; | |
/* eslint-disable @typescript-eslint/no-unused-vars */ | |
const _emits = { | |
'factory-failed': (error: Error, files: File[]) => true, | |
failed: (payload: { files: File[]; error: AxiosError }) => true, | |
uploaded: (payload: { files: File[]; response: AxiosResponse }) => true, | |
uploading: (payload: { files: File[]; config: AxiosRequestConfig }) => true, | |
reading: (payload: { files: File[]; config: AxiosRequestConfig }) => true, | |
}; | |
/* eslint-enable @typescript-eslint/no-unused-vars */ | |
function injectPlugin({ | |
props, | |
emit, | |
helpers, | |
}: { | |
props: ExtractPropTypes<typeof _props>; | |
emit: SetupContext<typeof _emits>['emit']; | |
helpers: { | |
queuedFiles: Ref<File[]>; | |
uploadedSize: Ref<number>; | |
uploadedFiles: Ref<File[]>; | |
isAlive(): boolean; | |
updateFileStatus(file: File, status: string, size?: number): void; | |
}; | |
}) { | |
const abortControllers = ref<AbortController[]>([]); | |
const promises = ref<Promise<unknown>[]>([]); | |
const workingThreads = ref(0); | |
const axiosProps = computed(() => ({ | |
url: getFn(props.url), | |
method: getFn(props.method), | |
headers: getFn(props.headers), | |
formFields: getFn(props.formFields), | |
fieldName: getFn(props.fieldName), | |
withCredentials: getFn(props.withCredentials), | |
sendRaw: getFn(props.sendRaw), | |
batch: getFn(props.batch), | |
})); | |
const isUploading = computed(() => workingThreads.value > 0); | |
const isBusy = computed(() => promises.value.length > 0); | |
let abortPromises: boolean; | |
function abort() { | |
abortControllers.value.forEach((controller) => { | |
controller.abort(); | |
}); | |
if (promises.value.length > 0) { | |
abortPromises = true; | |
} | |
} | |
function upload() { | |
const queue = helpers.queuedFiles.value.slice(0); | |
helpers.queuedFiles.value = []; | |
if (axiosProps.value.batch(queue)) { | |
runFactory(queue); | |
} else { | |
queue.forEach((file) => { | |
runFactory([file]); | |
}); | |
} | |
} | |
function runFactory(files: File[]) { | |
workingThreads.value++; | |
if (typeof props.factory !== 'function') { | |
performUpload(files, {}); | |
return; | |
} | |
const res = props.factory(files); | |
if (res instanceof Promise) { | |
promises.value.push(res); | |
const failed = (err: Error) => { | |
if (helpers.isAlive() === true) { | |
promises.value = promises.value.filter((p) => p !== res); | |
if (promises.value.length === 0) { | |
abortPromises = false; | |
} | |
helpers.queuedFiles.value = helpers.queuedFiles.value.concat(files); | |
files.forEach((f) => { | |
helpers.updateFileStatus(f, 'failed'); | |
}); | |
emit('factory-failed', err, files); | |
workingThreads.value--; | |
} | |
}; | |
res | |
.then((factory) => { | |
if (abortPromises === true) { | |
failed(new Error('Aborted')); | |
} else if (helpers.isAlive() === true) { | |
promises.value = promises.value.filter((p) => p !== res); | |
performUpload(files, factory); | |
} | |
}) | |
.catch(failed); | |
} else { | |
performUpload(files, res); | |
} | |
} | |
function performUpload(files: File[], factory: QUploaderFactoryObject) { | |
const data: Record<string, string> = {}; | |
const getProp = < | |
K extends keyof QUploaderFactoryObject, | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
Arg = Required<QUploaderFactoryObject>[K] extends (arg: infer P) => any | |
? P | |
: Required<QUploaderFactoryObject>[K], | |
R = Required<QUploaderFactoryObject>[K] | |
>( | |
name: K, | |
arg: Arg | |
) => { | |
return ( | |
factory[name] !== void 0 | |
? getFn(factory[name] as unknown)(arg) | |
: axiosProps.value[name](arg) | |
) as R extends (arg: Arg) => infer T | |
? T | |
: // Add undefined to props without a default | |
K extends 'method' | 'fieldName' | |
? R | |
: R | undefined; | |
}; | |
const url = getProp('url', files); | |
if (!url) { | |
console.error('q-uploader: invalid or no URL specified'); | |
workingThreads.value--; | |
return; | |
} | |
const fields = getProp('formFields', files); | |
fields !== void 0 && | |
fields.forEach(({ name, value }) => { | |
data[name] = value; | |
}); | |
let uploadIndex = 0, | |
uploadIndexSize = 0, | |
localUploadedSize = 0, | |
maxUploadSize = 0, | |
aborted: boolean; | |
function onUploadProgress(e: ProgressEvent) { | |
if (aborted === true) { | |
return; | |
} | |
const loaded = Math.min(maxUploadSize, e.loaded); | |
helpers.uploadedSize.value += loaded - localUploadedSize; | |
localUploadedSize = loaded; | |
let size = localUploadedSize - uploadIndexSize; | |
for (let i = uploadIndex; size > 0 && i < files.length; i++) { | |
const file = files[i], | |
uploaded = size > file.size; | |
if (uploaded) { | |
size -= file.size; | |
uploadIndex++; | |
uploadIndexSize += file.size; | |
helpers.updateFileStatus(file, 'uploading', file.size); | |
} else { | |
helpers.updateFileStatus(file, 'uploading', size); | |
return; | |
} | |
} | |
} | |
function onSuccess(response: AxiosResponse) { | |
helpers.uploadedFiles.value = helpers.uploadedFiles.value.concat(files); | |
files.forEach((f) => { | |
helpers.updateFileStatus(f, 'uploaded'); | |
}); | |
emit('uploaded', { files, response }); | |
} | |
function onFailure(error: AxiosError) { | |
aborted = true; | |
helpers.uploadedSize.value -= localUploadedSize; | |
helpers.queuedFiles.value = helpers.queuedFiles.value.concat(files); | |
files.forEach((f) => { | |
helpers.updateFileStatus(f, 'failed'); | |
}); | |
emit('failed', { files, error }); | |
} | |
const abortController = new AbortController(); | |
function cleanup() { | |
workingThreads.value--; | |
abortControllers.value = abortControllers.value.filter( | |
(controller) => controller !== abortController | |
); | |
} | |
// Manually annotate this to avoid TS complaining for the LiteralUnion type when used into the AxiosConfig | |
const method: Method = getProp('method', files); | |
const withCredentials = getProp('withCredentials', files); | |
const headers: Record<string, string> = {}; | |
const headersOptions = getProp('headers', files); | |
headersOptions !== void 0 && | |
headersOptions.forEach(({ name, value }) => { | |
headers[name] = value; | |
}); | |
const sendRaw = getProp('sendRaw', files); | |
files.forEach((file) => { | |
helpers.updateFileStatus(file, 'uploading', 0); | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any | |
(file as any).__abort = () => { | |
abortController.abort(); | |
}; | |
maxUploadSize += file.size; | |
}); | |
const config: AxiosRequestConfig = { | |
method, | |
url, | |
headers, | |
validateStatus: (status) => status < 400, | |
onUploadProgress, | |
withCredentials, | |
signal: abortController.signal, | |
handleErrorGlobally: false, | |
}; | |
function startUpload(config: AxiosRequestConfig) { | |
emit('uploading', { files, config }); | |
abortControllers.value.push(abortController); | |
axios.request(config).then(onSuccess).catch(onFailure).finally(cleanup); | |
} | |
if (sendRaw) { | |
startUpload({ ...config, data: new Blob(files) }); | |
} else { | |
// TODO: add a reading progress system too? | |
emit('reading', { files, config }); | |
Promise.all( | |
files.map(async (file) => (await readFile(file))?.toString() ?? '') | |
) | |
.then((encodedFiles) => { | |
encodedFiles.forEach((encodedFileData, index) => { | |
data[getProp('fieldName', files[index])] = encodedFileData; | |
}); | |
startUpload({ ...config, data }); | |
}) | |
.catch(onFailure); | |
} | |
} | |
return { | |
isUploading, | |
isBusy, | |
abort, | |
upload, | |
}; | |
} | |
export default { | |
props: _props, | |
emits: _emits, | |
injectPlugin, | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createUploaderComponent } from 'quasar'; | |
import axiosUploaderPlugin from './axios-uploader-plugin'; | |
export default createUploaderComponent({ | |
name: 'FormulaUploader', | |
...axiosUploaderPlugin, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment