Skip to content

Instantly share code, notes, and snippets.

@IlCallo
Last active January 17, 2022 15:01
Show Gist options
  • Save IlCallo/96b0ca99d7400e5a795e8b918dbc2982 to your computer and use it in GitHub Desktop.
Save IlCallo/96b0ca99d7400e5a795e8b918dbc2982 to your computer and use it in GitHub Desktop.
Axios uploader + TypeScript
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,
};
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