From 0abe53f8456e6e2ed0d15b45a81c186dbc030e22 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Mon, 17 Jun 2024 16:09:38 +0300 Subject: [PATCH] feat: extract public api to the composition class --- abstract/CTX.js | 1 - abstract/UploaderBlock.js | 339 +++--------------- abstract/UploaderPublicApi.js | 279 ++++++++++++++ abstract/ValidationManager.js | 14 +- blocks/CameraSource/CameraSource.js | 2 +- blocks/DropArea/DropArea.js | 6 +- blocks/ExternalSource/ExternalSource.js | 2 +- blocks/SimpleBtn/SimpleBtn.js | 2 +- blocks/SourceBtn/SourceBtn.js | 4 +- blocks/UploadList/UploadList.js | 12 +- blocks/UrlSource/UrlSource.js | 2 +- types/test/lr-config.test-d.tsx | 8 +- types/test/lr-upload-ctx-provider.test-d.tsx | 2 +- .../validateCollectionUploadError.js | 4 +- .../validators/collection/validateMultiple.js | 10 +- utils/validators/file/validateFileType.js | 8 +- utils/validators/file/validateIsImage.js | 8 +- utils/validators/file/validateMaxSizeLimit.js | 6 +- utils/validators/file/validateUploadError.js | 7 +- 19 files changed, 383 insertions(+), 333 deletions(-) create mode 100644 abstract/UploaderPublicApi.js diff --git a/abstract/CTX.js b/abstract/CTX.js index 520a8abc7..92d88c7d3 100644 --- a/abstract/CTX.js +++ b/abstract/CTX.js @@ -25,7 +25,6 @@ export const uploaderBlockCtx = (fnCtx) => ({ '*uploadList': [], '*focusedEntry': null, '*uploadQueue': new Queue(1), - '*uploadCollection': null, /** @type {ReturnType[]} */ '*collectionErrors': [], /** @type {import('../types').OutputCollectionState | null} */ diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 0125de8f4..b704cfb96 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -6,20 +6,16 @@ import { uploadFileGroup } from '@uploadcare/upload-client'; import { calculateMaxCenteredCropFrame } from '../blocks/CloudImageEditor/src/crop-utils.js'; import { parseCropPreset } from '../blocks/CloudImageEditor/src/lib/parseCropPreset.js'; import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; -import { UploadSource } from '../blocks/utils/UploadSource.js'; -import { serializeCsv } from '../blocks/utils/comma-separated.js'; import { debounce } from '../blocks/utils/debounce.js'; import { customUserAgent } from '../blocks/utils/userAgent.js'; import { createCdnUrl, createCdnUrlModifiers } from '../utils/cdn-utils.js'; -import { IMAGE_ACCEPT_LIST, fileIsImage, mergeFileTypes } from '../utils/fileTypes.js'; import { stringToArray } from '../utils/stringToArray.js'; import { uploaderBlockCtx } from './CTX.js'; -import { TypedCollection } from './TypedCollection.js'; -import { buildOutputCollectionState } from './buildOutputCollectionState.js'; -import { uploadEntrySchema } from './uploadEntrySchema.js'; -import { parseCdnUrl } from '../utils/parseCdnUrl.js'; import { SecureUploadsManager } from './SecureUploadsManager.js'; +import { TypedCollection } from './TypedCollection.js'; +import { UploaderPublicApi } from './UploaderPublicApi.js'; import { ValidationManager } from './ValidationManager.js'; +import { uploadEntrySchema } from './uploadEntrySchema.js'; export class UploaderBlock extends ActivityBlock { couldBeCtxOwner = false; @@ -39,14 +35,18 @@ export class UploaderBlock extends ActivityBlock { initCallback() { super.initCallback(); - if (!this.$['*uploadCollection']) { + if (!this.has('*uploadCollection')) { let uploadCollection = new TypedCollection({ typedSchema: uploadEntrySchema, watchList: ['uploadProgress', 'uploadError', 'fileInfo', 'errors', 'cdnUrl', 'isUploading'], }); - this.$['*uploadCollection'] = uploadCollection; + this.add('*uploadCollection', uploadCollection); + } + + if (!this.has('*publicApi')) { + this.add('*publicApi', new UploaderPublicApi(this)); } - // + if (!this.has('*validationManager')) { this.add('*validationManager', new ValidationManager(this)); } @@ -56,9 +56,28 @@ export class UploaderBlock extends ActivityBlock { } } - /** @returns {ValidationManager | null} */ + /** @returns {ValidationManager} */ get validationManager() { - return this.has('*validationManager') ? this.$['*validationManager'] : null; + if (!this.has('*validationManager')) { + throw new Error('Unexpected error: ValidationManager is not initialized'); + } + return this.$['*validationManager']; + } + + /** @returns {UploaderPublicApi} */ + get api() { + if (!this.has('*publicApi')) { + throw new Error('Unexpected error: UploaderPublicApi is not initialized'); + } + return this.$['*publicApi']; + } + + /** @returns {TypedCollection} */ + get uploadCollection() { + if (!this.has('*uploadCollection')) { + throw new Error('Unexpected error: TypedCollection is not initialized'); + } + return this.$['*uploadCollection']; } destroyCtxCallback() { @@ -90,142 +109,6 @@ export class UploaderBlock extends ActivityBlock { } } - // TODO: Probably we should not allow user to override `source` property - - /** - * @param {string} url - * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromUrl(url, { silent, fileName, source } = {}) { - const internalId = this.uploadCollection.add({ - externalUrl: url, - fileName: fileName ?? null, - silent: silent ?? false, - source: source ?? UploadSource.API, - }); - return this.getOutputItem(internalId); - } - - /** - * @param {string} uuid - * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromUuid(uuid, { silent, fileName, source } = {}) { - const internalId = this.uploadCollection.add({ - uuid, - fileName: fileName ?? null, - silent: silent ?? false, - source: source ?? UploadSource.API, - }); - return this.getOutputItem(internalId); - } - - /** - * @param {string} cdnUrl - * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromCdnUrl(cdnUrl, { silent, fileName, source } = {}) { - const parsedCdnUrl = parseCdnUrl({ url: cdnUrl, cdnBase: this.cfg.cdnCname }); - if (!parsedCdnUrl) { - throw new Error('Invalid CDN URL'); - } - const internalId = this.uploadCollection.add({ - uuid: parsedCdnUrl.uuid, - cdnUrl, - cdnUrlModifiers: parsedCdnUrl.cdnUrlModifiers, - fileName: fileName ?? parsedCdnUrl.filename ?? null, - silent: silent ?? false, - source: source ?? UploadSource.API, - }); - return this.getOutputItem(internalId); - } - - /** - * @param {File} file - * @param {{ silent?: boolean; fileName?: string; source?: string; fullPath?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromObject(file, { silent, fileName, source, fullPath } = {}) { - const internalId = this.uploadCollection.add({ - file, - isImage: fileIsImage(file), - mimeType: file.type, - fileName: fileName ?? file.name, - fileSize: file.size, - silent: silent ?? false, - source: source ?? UploadSource.API, - fullPath: fullPath ?? null, - }); - return this.getOutputItem(internalId); - } - - /** @param {string} internalId */ - removeFileByInternalId(internalId) { - if (!this.uploadCollection.read(internalId)) { - throw new Error(`File with internalId ${internalId} not found`); - } - this.uploadCollection.remove(internalId); - } - - removeAllFiles() { - this.uploadCollection.clearAll(); - } - - uploadAll = () => { - const itemsToUpload = this.uploadCollection.items().filter((id) => { - const entry = this.uploadCollection.read(id); - return !entry.getValue('isRemoved') && !entry.getValue('isUploading') && !entry.getValue('fileInfo'); - }); - - if (itemsToUpload.length === 0) { - return; - } - - this.$['*uploadTrigger'] = new Set(itemsToUpload); - this.emit( - EventType.COMMON_UPLOAD_START, - /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.getOutputCollectionState()), - ); - }; - - /** @param {{ captureCamera?: boolean }} options */ - openSystemDialog(options = {}) { - let accept = serializeCsv(mergeFileTypes([this.cfg.accept ?? '', ...(this.cfg.imgOnly ? IMAGE_ACCEPT_LIST : [])])); - - if (this.cfg.accept && !!this.cfg.imgOnly) { - console.warn( - 'There could be a mistake.\n' + - 'Both `accept` and `imgOnly` parameters are set.\n' + - 'The value of `accept` will be concatenated with the internal image mime types list.', - ); - } - this.fileInput = document.createElement('input'); - this.fileInput.type = 'file'; - this.fileInput.multiple = this.cfg.multiple; - if (options.captureCamera) { - this.fileInput.capture = this.cfg.cameraCapture; - this.fileInput.accept = 'image/*'; - } else { - this.fileInput.accept = accept; - } - this.fileInput.dispatchEvent(new MouseEvent('click')); - this.fileInput.onchange = () => { - // @ts-ignore TODO: fix this - [...this.fileInput['files']].forEach((file) => - this.addFileFromObject(file, { source: options.captureCamera ? UploadSource.CAMERA : UploadSource.LOCAL }), - ); - // To call uploadTrigger UploadList should draw file items first: - this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; - this.setOrAddState('*modalActive', true); - // @ts-ignore TODO: fix this - this.fileInput['value'] = ''; - this.fileInput = null; - }; - } - /** @type {string[]} */ get sourceList() { /** @type {string[]} */ @@ -237,62 +120,6 @@ export class UploaderBlock extends ActivityBlock { return list; } - /** @param {Boolean} [force] */ - initFlow(force = false) { - if (this.uploadCollection.size > 0 && !force) { - this.set$({ - '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, - }); - this.setOrAddState('*modalActive', true); - } else { - if (this.sourceList?.length === 1) { - const srcKey = this.sourceList[0]; - - // TODO: We should refactor those handlers - if (srcKey === 'local') { - this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; - this?.['openSystemDialog'](); - return; - } - - /** @type {Set} */ - const blocksRegistry = this.$['*blocksRegistry']; - /** - * @param {import('./Block').Block} block - * @returns {block is import('../blocks/SourceBtn/SourceBtn.js').SourceBtn} - */ - const isSourceBtn = (block) => 'type' in block && block.type === srcKey; - const sourceBtnBlock = [...blocksRegistry].find(isSourceBtn); - // TODO: This is weird that we have this logic inside UI component, we should consider to move it somewhere else - sourceBtnBlock?.activate(); - if (this.$['*currentActivity']) { - this.setOrAddState('*modalActive', true); - } - } else { - // Multiple sources case: - this.set$({ - '*currentActivity': ActivityBlock.activities.START_FROM, - }); - this.setOrAddState('*modalActive', true); - } - } - } - - doneFlow() { - this.set$({ - '*currentActivity': this.doneActivity, - '*history': this.doneActivity ? [this.doneActivity] : [], - }); - if (!this.$['*currentActivity']) { - this.setOrAddState('*modalActive', false); - } - } - - /** @returns {TypedCollection} */ - get uploadCollection() { - return this.$['*uploadCollection']; - } - /** * @private * @param {import('../types').OutputCollectionState} collectionState @@ -310,10 +137,10 @@ export class UploaderBlock extends ActivityBlock { } this.$['*groupInfo'] = resp; const collectionStateWithGroup = /** @type {import('../types').OutputCollectionState<'success', 'has-group'>} */ ( - this.getOutputCollectionState() + this.api.getOutputCollectionState() ); this.emit(EventType.GROUP_CREATED, collectionStateWithGroup); - this.emit(EventType.CHANGE, () => this.getOutputCollectionState(), { debounce: true }); + this.emit(EventType.CHANGE, () => this.api.getOutputCollectionState(), { debounce: true }); this.$['*collectionState'] = collectionStateWithGroup; } @@ -323,9 +150,9 @@ export class UploaderBlock extends ActivityBlock { if (data.length !== this.uploadCollection.size) { return; } - const collectionState = this.getOutputCollectionState(); + const collectionState = this.api.getOutputCollectionState(); this.$['*collectionState'] = collectionState; - this.emit(EventType.CHANGE, () => this.getOutputCollectionState(), { debounce: true }); + this.emit(EventType.CHANGE, () => this.api.getOutputCollectionState(), { debounce: true }); if (this.cfg.groupOutput && collectionState.totalCount > 0 && collectionState.status === 'success') { this._createGroup(collectionState); @@ -341,14 +168,12 @@ export class UploaderBlock extends ActivityBlock { if (added.size || removed.size) { this.$['*groupInfo'] = null; } - if (this.validationManager) { - this.validationManager.runFileValidators(); - this.validationManager.runCollectionValidators(); - } + this.validationManager.runFileValidators(); + this.validationManager.runCollectionValidators(); for (const entry of added) { if (!entry.getValue('silent')) { - this.emit(EventType.FILE_ADDED, this.getOutputItem(entry.uid)); + this.emit(EventType.FILE_ADDED, this.api.getOutputItem(entry.uid)); } } @@ -363,7 +188,7 @@ export class UploaderBlock extends ActivityBlock { uploadProgress: 0, }); URL.revokeObjectURL(entry?.getValue('thumbUrl')); - this.emit(EventType.FILE_REMOVED, this.getOutputItem(entry.uid)); + this.emit(EventType.FILE_REMOVED, this.api.getOutputItem(entry.uid)); } this.$['*uploadList'] = entries.map((uid) => { @@ -394,14 +219,14 @@ export class UploaderBlock extends ActivityBlock { entriesToRunValidation.length > 0 && setTimeout(() => { // We can't modify entry properties in the same tick, so we need to wait a bit - if (this.validationManager) this.validationManager.runFileValidators(entriesToRunValidation); + this.validationManager.runFileValidators(entriesToRunValidation); }); if (changeMap.uploadProgress) { for (const entryId of changeMap.uploadProgress) { const { isUploading, silent } = Data.getCtx(entryId).store; if (isUploading && !silent) { - this.emit(EventType.FILE_UPLOAD_PROGRESS, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_PROGRESS, this.api.getOutputItem(entryId)); } } @@ -411,7 +236,7 @@ export class UploaderBlock extends ActivityBlock { for (const entryId of changeMap.isUploading) { const { isUploading, silent } = Data.getCtx(entryId).store; if (isUploading && !silent) { - this.emit(EventType.FILE_UPLOAD_START, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_START, this.api.getOutputItem(entryId)); } } } @@ -419,7 +244,7 @@ export class UploaderBlock extends ActivityBlock { for (const entryId of changeMap.fileInfo) { const { fileInfo, silent } = Data.getCtx(entryId).store; if (fileInfo && !silent) { - this.emit(EventType.FILE_UPLOAD_SUCCESS, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_SUCCESS, this.api.getOutputItem(entryId)); } } if (this.cfg.cropPreset) { @@ -434,7 +259,7 @@ export class UploaderBlock extends ActivityBlock { if (errorItems.length === 0 && uploadCollection.size === loadedItems.length) { this.emit( EventType.COMMON_UPLOAD_SUCCESS, - /** @type {import('../types').OutputCollectionState<'success'>} */ (this.getOutputCollectionState()), + /** @type {import('../types').OutputCollectionState<'success'>} */ (this.api.getOutputCollectionState()), ); } } @@ -442,10 +267,11 @@ export class UploaderBlock extends ActivityBlock { for (const entryId of changeMap.errors) { const { errors } = Data.getCtx(entryId).store; if (errors.length > 0) { - this.emit(EventType.FILE_UPLOAD_FAILED, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_FAILED, this.api.getOutputItem(entryId)); this.emit( EventType.COMMON_UPLOAD_FAILED, - () => /** @type {import('../types').OutputCollectionState<'failed'>} */ (this.getOutputCollectionState()), + () => + /** @type {import('../types').OutputCollectionState<'failed'>} */ (this.api.getOutputCollectionState()), { debounce: true }, ); } @@ -456,7 +282,7 @@ export class UploaderBlock extends ActivityBlock { return !!this.uploadCollection.read(uid)?.getValue('cdnUrl'); }); uids.forEach((uid) => { - this.emit(EventType.FILE_URL_CHANGED, this.getOutputItem(uid)); + this.emit(EventType.FILE_URL_CHANGED, this.api.getOutputItem(uid)); }); this.$['*groupInfo'] = null; @@ -482,7 +308,7 @@ export class UploaderBlock extends ActivityBlock { this.$['*commonProgress'] = progress; this.emit( EventType.COMMON_UPLOAD_PROGRESS, - /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.getOutputCollectionState()), + /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.api.getOutputCollectionState()), ); }; @@ -533,7 +359,7 @@ export class UploaderBlock extends ActivityBlock { async getMetadataFor(entryId) { const configValue = this.cfg.metadata || undefined; if (typeof configValue === 'function') { - const outputFileEntry = this.getOutputItem(entryId); + const outputFileEntry = this.api.getOutputItem(entryId); const metadata = await configValue(outputFileEntry); return metadata; } @@ -567,69 +393,12 @@ export class UploaderBlock extends ActivityBlock { return options; } - /** - * @template {import('../types').OutputFileStatus} TStatus - * @param {string} entryId - * @returns {import('../types/exported.js').OutputFileEntry} - */ - getOutputItem(entryId) { - const uploadEntryData = /** @type {import('./uploadEntrySchema.js').UploadEntry} */ (Data.getCtx(entryId).store); - - /** @type {import('@uploadcare/upload-client').UploadcareFile?} */ - const fileInfo = uploadEntryData.fileInfo; - - /** @type {import('../types').OutputFileEntry['status']} */ - let status = uploadEntryData.isRemoved - ? 'removed' - : uploadEntryData.errors.length > 0 - ? 'failed' - : !!uploadEntryData.fileInfo - ? 'success' - : uploadEntryData.isUploading - ? 'uploading' - : 'idle'; - - /** @type {unknown} */ - const outputItem = { - uuid: fileInfo?.uuid ?? uploadEntryData.uuid ?? null, - internalId: entryId, - name: fileInfo?.originalFilename ?? uploadEntryData.fileName, - size: fileInfo?.size ?? uploadEntryData.fileSize, - isImage: fileInfo?.isImage ?? uploadEntryData.isImage, - mimeType: fileInfo?.mimeType ?? uploadEntryData.mimeType, - file: uploadEntryData.file, - externalUrl: uploadEntryData.externalUrl, - cdnUrlModifiers: uploadEntryData.cdnUrlModifiers, - cdnUrl: uploadEntryData.cdnUrl ?? fileInfo?.cdnUrl ?? null, - fullPath: uploadEntryData.fullPath, - uploadProgress: uploadEntryData.uploadProgress, - fileInfo: fileInfo ?? null, - metadata: uploadEntryData.metadata ?? fileInfo?.metadata ?? null, - isSuccess: status === 'success', - isUploading: status === 'uploading', - isFailed: status === 'failed', - isRemoved: status === 'removed', - errors: /** @type {import('../types/exported.js').OutputFileEntry['errors']} */ (uploadEntryData.errors), - status, - }; - - return /** @type {import('../types/exported.js').OutputFileEntry} */ (outputItem); - } - - /** - * @param {(item: import('./TypedData.js').TypedData) => Boolean} [checkFn] - * @returns {import('../types/exported.js').OutputFileEntry[]} - */ - getOutputData(checkFn) { - const entriesIds = checkFn ? this.uploadCollection.findItems(checkFn) : this.uploadCollection.items(); - const data = entriesIds.map((itemId) => this.getOutputItem(itemId)); + /** @returns {import('../types/exported.js').OutputFileEntry[]} */ + getOutputData() { + const entriesIds = this.uploadCollection.items(); + const data = entriesIds.map((itemId) => this.api.getOutputItem(itemId)); return data; } - - /** @template {import('../types').OutputCollectionStatus} TStatus */ - getOutputCollectionState() { - return /** @type {ReturnType>} */ (buildOutputCollectionState(this)); - } } /** @enum {String} */ diff --git a/abstract/UploaderPublicApi.js b/abstract/UploaderPublicApi.js new file mode 100644 index 000000000..3d6abd41a --- /dev/null +++ b/abstract/UploaderPublicApi.js @@ -0,0 +1,279 @@ +// @ts-check +import { ActivityBlock } from './ActivityBlock.js'; + +import { Data } from '@symbiotejs/symbiote'; +import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; +import { UploadSource } from '../blocks/utils/UploadSource.js'; +import { serializeCsv } from '../blocks/utils/comma-separated.js'; +import { IMAGE_ACCEPT_LIST, fileIsImage, mergeFileTypes } from '../utils/fileTypes.js'; +import { parseCdnUrl } from '../utils/parseCdnUrl.js'; +import { buildOutputCollectionState } from './buildOutputCollectionState.js'; + +export class UploaderPublicApi { + /** + * @private + * @type {import('./UploaderBlock.js').UploaderBlock} + */ + _ctx; + + /** @param {import('./UploaderBlock.js').UploaderBlock} ctx */ + constructor(ctx) { + this._ctx = ctx; + } + + /** @private */ + get _uploadCollection() { + return this._ctx.uploadCollection; + } + + get cfg() { + return this._ctx.cfg; + } + + get l10n() { + return this._ctx.l10n; + } + + /** + * TODO: Probably we should not allow user to override `source` property + * + * @param {string} url + * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromUrl(url, { silent, fileName, source } = {}) { + const internalId = this._uploadCollection.add({ + externalUrl: url, + fileName: fileName ?? null, + silent: silent ?? false, + source: source ?? UploadSource.API, + }); + return this.getOutputItem(internalId); + } + + /** + * @param {string} uuid + * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromUuid(uuid, { silent, fileName, source } = {}) { + const internalId = this._uploadCollection.add({ + uuid, + fileName: fileName ?? null, + silent: silent ?? false, + source: source ?? UploadSource.API, + }); + return this.getOutputItem(internalId); + } + + /** + * @param {string} cdnUrl + * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromCdnUrl(cdnUrl, { silent, fileName, source } = {}) { + const parsedCdnUrl = parseCdnUrl({ url: cdnUrl, cdnBase: this.cfg.cdnCname }); + if (!parsedCdnUrl) { + throw new Error('Invalid CDN URL'); + } + const internalId = this._uploadCollection.add({ + uuid: parsedCdnUrl.uuid, + cdnUrl, + cdnUrlModifiers: parsedCdnUrl.cdnUrlModifiers, + fileName: fileName ?? parsedCdnUrl.filename ?? null, + silent: silent ?? false, + source: source ?? UploadSource.API, + }); + return this.getOutputItem(internalId); + } + + /** + * @param {File} file + * @param {{ silent?: boolean; fileName?: string; source?: string; fullPath?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromObject(file, { silent, fileName, source, fullPath } = {}) { + const internalId = this._uploadCollection.add({ + file, + isImage: fileIsImage(file), + mimeType: file.type, + fileName: fileName ?? file.name, + fileSize: file.size, + silent: silent ?? false, + source: source ?? UploadSource.API, + fullPath: fullPath ?? null, + }); + return this.getOutputItem(internalId); + } + + /** @param {string} internalId */ + removeFileByInternalId(internalId) { + if (!this._uploadCollection.read(internalId)) { + throw new Error(`File with internalId ${internalId} not found`); + } + this._uploadCollection.remove(internalId); + } + + removeAllFiles() { + this._uploadCollection.clearAll(); + } + + uploadAll = () => { + const itemsToUpload = this._uploadCollection.items().filter((id) => { + const entry = this._uploadCollection.read(id); + return !entry.getValue('isRemoved') && !entry.getValue('isUploading') && !entry.getValue('fileInfo'); + }); + + if (itemsToUpload.length === 0) { + return; + } + + this._ctx.$['*uploadTrigger'] = new Set(itemsToUpload); + this._ctx.emit( + EventType.COMMON_UPLOAD_START, + /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.getOutputCollectionState()), + ); + }; + + /** @param {{ captureCamera?: boolean }} options */ + openSystemDialog(options = {}) { + let accept = serializeCsv(mergeFileTypes([this.cfg.accept ?? '', ...(this.cfg.imgOnly ? IMAGE_ACCEPT_LIST : [])])); + + if (this.cfg.accept && !!this.cfg.imgOnly) { + console.warn( + 'There could be a mistake.\n' + + 'Both `accept` and `imgOnly` parameters are set.\n' + + 'The value of `accept` will be concatenated with the internal image mime types list.', + ); + } + this.fileInput = document.createElement('input'); + this.fileInput.type = 'file'; + this.fileInput.multiple = this.cfg.multiple; + if (options.captureCamera) { + this.fileInput.capture = this.cfg.cameraCapture; + this.fileInput.accept = 'image/*'; + } else { + this.fileInput.accept = accept; + } + this.fileInput.dispatchEvent(new MouseEvent('click')); + this.fileInput.onchange = () => { + // @ts-ignore TODO: fix this + [...this.fileInput['files']].forEach((file) => + this.addFileFromObject(file, { source: options.captureCamera ? UploadSource.CAMERA : UploadSource.LOCAL }), + ); + // To call uploadTrigger UploadList should draw file items first: + this._ctx.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; + this._ctx.setOrAddState('*modalActive', true); + // @ts-ignore TODO: fix this + this.fileInput['value'] = ''; + this.fileInput = null; + }; + } + + /** + * @template {import('../types').OutputFileStatus} TStatus + * @param {string} entryId + * @returns {import('../types/exported.js').OutputFileEntry} + */ + getOutputItem(entryId) { + const uploadEntryData = /** @type {import('./uploadEntrySchema.js').UploadEntry} */ (Data.getCtx(entryId).store); + + /** @type {import('@uploadcare/upload-client').UploadcareFile?} */ + const fileInfo = uploadEntryData.fileInfo; + + /** @type {import('../types').OutputFileEntry['status']} */ + let status = uploadEntryData.isRemoved + ? 'removed' + : uploadEntryData.errors.length > 0 + ? 'failed' + : !!uploadEntryData.fileInfo + ? 'success' + : uploadEntryData.isUploading + ? 'uploading' + : 'idle'; + + /** @type {unknown} */ + const outputItem = { + uuid: fileInfo?.uuid ?? uploadEntryData.uuid ?? null, + internalId: entryId, + name: fileInfo?.originalFilename ?? uploadEntryData.fileName, + size: fileInfo?.size ?? uploadEntryData.fileSize, + isImage: fileInfo?.isImage ?? uploadEntryData.isImage, + mimeType: fileInfo?.mimeType ?? uploadEntryData.mimeType, + file: uploadEntryData.file, + externalUrl: uploadEntryData.externalUrl, + cdnUrlModifiers: uploadEntryData.cdnUrlModifiers, + cdnUrl: uploadEntryData.cdnUrl ?? fileInfo?.cdnUrl ?? null, + fullPath: uploadEntryData.fullPath, + uploadProgress: uploadEntryData.uploadProgress, + fileInfo: fileInfo ?? null, + metadata: uploadEntryData.metadata ?? fileInfo?.metadata ?? null, + isSuccess: status === 'success', + isUploading: status === 'uploading', + isFailed: status === 'failed', + isRemoved: status === 'removed', + errors: /** @type {import('../types/exported.js').OutputFileEntry['errors']} */ (uploadEntryData.errors), + status, + }; + + return /** @type {import('../types/exported.js').OutputFileEntry} */ (outputItem); + } + + /** @template {import('../types').OutputCollectionStatus} TStatus */ + getOutputCollectionState() { + return /** @type {ReturnType>} */ ( + buildOutputCollectionState(this._ctx) + ); + } + + /** @param {Boolean} [force] */ + initFlow(force = false) { + if (this._uploadCollection.size > 0 && !force) { + this._ctx.set$({ + '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, + }); + this._ctx.setOrAddState('*modalActive', true); + } else { + if (this._ctx.sourceList?.length === 1) { + const srcKey = this._ctx.sourceList[0]; + + // TODO: We should refactor those handlers + if (srcKey === 'local') { + this._ctx.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; + this.openSystemDialog(); + return; + } + + /** @type {Set} */ + const blocksRegistry = this._ctx.$['*blocksRegistry']; + /** + * @param {import('./Block').Block} block + * @returns {block is import('../blocks/SourceBtn/SourceBtn.js').SourceBtn} + */ + const isSourceBtn = (block) => 'type' in block && block.type === srcKey; + const sourceBtnBlock = [...blocksRegistry].find(isSourceBtn); + // TODO: This is weird that we have this logic inside UI component, we should consider to move it somewhere else + sourceBtnBlock?.activate(); + if (this._ctx.$['*currentActivity']) { + this._ctx.setOrAddState('*modalActive', true); + } + } else { + // Multiple sources case: + this._ctx.set$({ + '*currentActivity': ActivityBlock.activities.START_FROM, + }); + this._ctx.setOrAddState('*modalActive', true); + } + } + } + + doneFlow() { + this._ctx.set$({ + '*currentActivity': this._ctx.doneActivity, + '*history': this._ctx.doneActivity ? [this._ctx.doneActivity] : [], + }); + if (!this._ctx.$['*currentActivity']) { + this._ctx.setOrAddState('*modalActive', false); + } + } +} diff --git a/abstract/ValidationManager.js b/abstract/ValidationManager.js index 045573bfa..0574a15dd 100644 --- a/abstract/ValidationManager.js +++ b/abstract/ValidationManager.js @@ -11,7 +11,7 @@ import { validateMultiple, validateCollectionUploadError } from '../utils/valida /** * @typedef {( * outputEntry: import('../types').OutputFileEntry, - * ctx: import('./UploaderBlock.js').UploaderBlock, + * api: import('./UploaderPublicApi.js').UploaderPublicApi, * ) => undefined | import('../types').OutputErrorFile} FuncFileValidator */ @@ -22,7 +22,7 @@ import { validateMultiple, validateCollectionUploadError } from '../utils/valida * import('../types').OutputCollectionStatus * > * >, - * ctx: import('./UploaderBlock.js').UploaderBlock, + * api: import('./UploaderPublicApi.js').UploaderPublicApi, * ) => undefined | import('../types').OutputErrorCollection} FuncCollectionValidator */ @@ -76,7 +76,7 @@ export class ValidationManager { } runCollectionValidators() { - const collection = this._blockInstance.getOutputCollectionState(); + const collection = this._blockInstance.api.getOutputCollectionState(); const errors = []; for (const validator of [ @@ -84,7 +84,7 @@ export class ValidationManager { ...this._addCustomTypeToValidators(this._blockInstance.cfg.collectionValidators), ]) { try { - const errorOrErrors = validator(collection, this._blockInstance); + const errorOrErrors = validator(collection, this._blockInstance.api); if (!errorOrErrors) { continue; } @@ -107,7 +107,7 @@ export class ValidationManager { EventType.COMMON_UPLOAD_FAILED, () => /** @type {import('../types').OutputCollectionState<'failed'>} */ ( - this._blockInstance.getOutputCollectionState() + this._blockInstance.api.getOutputCollectionState() ), { debounce: true }, ); @@ -119,7 +119,7 @@ export class ValidationManager { * @param {import('./TypedData.js').TypedData} entry */ _runFileValidatorsForEntry(entry) { - const outputEntry = this._blockInstance.getOutputItem(entry.uid); + const outputEntry = this._blockInstance.api.getOutputItem(entry.uid); const errors = []; for (const validator of [ @@ -127,7 +127,7 @@ export class ValidationManager { ...this._addCustomTypeToValidators(this._blockInstance.cfg.fileValidators), ]) { try { - const error = validator(outputEntry, this._blockInstance); + const error = validator(outputEntry, this._blockInstance.api); if (!error) { continue; } diff --git a/blocks/CameraSource/CameraSource.js b/blocks/CameraSource/CameraSource.js index af01923aa..a47be2ad3 100644 --- a/blocks/CameraSource/CameraSource.js +++ b/blocks/CameraSource/CameraSource.js @@ -171,7 +171,7 @@ export class CameraSource extends UploaderBlock { lastModified: date, type: format, }); - this.addFileFromObject(file, { source: UploadSource.CAMERA }); + this.api.addFileFromObject(file, { source: UploadSource.CAMERA }); this.set$({ '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, }); diff --git a/blocks/DropArea/DropArea.js b/blocks/DropArea/DropArea.js index 01225a655..c68c6be54 100644 --- a/blocks/DropArea/DropArea.js +++ b/blocks/DropArea/DropArea.js @@ -108,9 +108,9 @@ export class DropArea extends UploaderBlock { items.forEach((/** @type {import('./getDropItems.js').DropItem} */ item) => { if (item.type === 'url') { - this.addFileFromUrl(item.url, { source: UploadSource.DROP_AREA }); + this.api.addFileFromUrl(item.url, { source: UploadSource.DROP_AREA }); } else if (item.type === 'file') { - this.addFileFromObject(item.file, { source: UploadSource.DROP_AREA, fullPath: item.fullPath }); + this.api.addFileFromObject(item.file, { source: UploadSource.DROP_AREA, fullPath: item.fullPath }); } }); if (this.uploadCollection.size) { @@ -166,7 +166,7 @@ export class DropArea extends UploaderBlock { if (this.$.isClickable) { // @private this._onAreaClicked = () => { - this.openSystemDialog(); + this.api.openSystemDialog(); }; this.addEventListener('click', this._onAreaClicked); } diff --git a/blocks/ExternalSource/ExternalSource.js b/blocks/ExternalSource/ExternalSource.js index 136e4f5c2..c22b58e05 100644 --- a/blocks/ExternalSource/ExternalSource.js +++ b/blocks/ExternalSource/ExternalSource.js @@ -49,7 +49,7 @@ export class ExternalSource extends UploaderBlock { const url = this.extractUrlFromMessage(message); const { filename } = message; const { externalSourceType } = this.activityParams; - this.addFileFromUrl(url, { fileName: filename, source: externalSourceType }); + this.api.addFileFromUrl(url, { fileName: filename, source: externalSourceType }); } this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; diff --git a/blocks/SimpleBtn/SimpleBtn.js b/blocks/SimpleBtn/SimpleBtn.js index 31121093b..e4130ba87 100644 --- a/blocks/SimpleBtn/SimpleBtn.js +++ b/blocks/SimpleBtn/SimpleBtn.js @@ -11,7 +11,7 @@ export class SimpleBtn extends UploaderBlock { ...this.init$, withDropZone: true, onClick: () => { - this.initFlow(); + this.api.initFlow(); }, 'button-text': '', }; diff --git a/blocks/SourceBtn/SourceBtn.js b/blocks/SourceBtn/SourceBtn.js index 3eeeddddb..76c45f128 100644 --- a/blocks/SourceBtn/SourceBtn.js +++ b/blocks/SourceBtn/SourceBtn.js @@ -39,7 +39,7 @@ export class SourceBtn extends UploaderBlock { this.registerType({ type: UploaderBlock.sourceTypes.LOCAL, activate: () => { - this.openSystemDialog(); + this.api.openSystemDialog(); return false; }, }); @@ -54,7 +54,7 @@ export class SourceBtn extends UploaderBlock { activate: () => { const supportsCapture = 'capture' in document.createElement('input'); if (supportsCapture) { - this.openSystemDialog({ captureCamera: true }); + this.api.openSystemDialog({ captureCamera: true }); } return !supportsCapture; }, diff --git a/blocks/UploadList/UploadList.js b/blocks/UploadList/UploadList.js index 57daa1345..8813f81fe 100644 --- a/blocks/UploadList/UploadList.js +++ b/blocks/UploadList/UploadList.js @@ -35,16 +35,16 @@ export class UploadList extends UploaderBlock { hasFiles: false, onAdd: () => { - this.initFlow(true); + this.api.initFlow(true); }, onUpload: () => { this.emit(EventType.UPLOAD_CLICK); - this.uploadAll(); + this.api.uploadAll(); this._throttledHandleCollectionUpdate(); }, onDone: () => { - this.emit(EventType.DONE_CLICK, this.getOutputCollectionState()); - this.doneFlow(); + this.emit(EventType.DONE_CLICK, this.api.getOutputCollectionState()); + this.api.doneFlow(); }, onCancel: () => { this.uploadCollection.clearAll(); @@ -66,7 +66,7 @@ export class UploadList extends UploaderBlock { /** @private */ _updateUploadsState() { - const collectionState = this.getOutputCollectionState(); + const collectionState = this.api.getOutputCollectionState(); /** @type {Summary} */ const summary = { total: collectionState.totalCount, @@ -163,7 +163,7 @@ export class UploadList extends UploaderBlock { }); if (!this.cfg.confirmUpload) { - this.uploadAll(); + this.api.uploadAll(); } }, false, diff --git a/blocks/UrlSource/UrlSource.js b/blocks/UrlSource/UrlSource.js index d71d79008..255a22ab9 100644 --- a/blocks/UrlSource/UrlSource.js +++ b/blocks/UrlSource/UrlSource.js @@ -13,7 +13,7 @@ export class UrlSource extends UploaderBlock { e.preventDefault(); let url = this.ref.input['value']; - this.addFileFromUrl(url, { source: UploadSource.URL_TAB }); + this.api.addFileFromUrl(url, { source: UploadSource.URL_TAB }); this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; }, onCancel: () => { diff --git a/types/test/lr-config.test-d.tsx b/types/test/lr-config.test-d.tsx index f5e8ca914..584f2e502 100644 --- a/types/test/lr-config.test-d.tsx +++ b/types/test/lr-config.test-d.tsx @@ -75,13 +75,13 @@ import { OutputFileEntry, FuncCollectionValidator, FuncFileValidator } from '../ if (ref.current) { const config = ref.current; - const maxSize: FuncFileValidator = (outputEntry, block) => ({ - message: block.l10n('images-only-accepted'), + const maxSize: FuncFileValidator = (outputEntry, api) => ({ + message: api.l10n('images-only-accepted'), payload: { entry: outputEntry }, }) - const maxCollection: FuncCollectionValidator = (collection, block) => ({ - message: block.l10n('some-files-were-not-uploaded'), + const maxCollection: FuncCollectionValidator = (collection, api) => ({ + message: api.l10n('some-files-were-not-uploaded'), }) config.fileValidators = [maxSize] diff --git a/types/test/lr-upload-ctx-provider.test-d.tsx b/types/test/lr-upload-ctx-provider.test-d.tsx index 994d39d8b..ed461bc57 100644 --- a/types/test/lr-upload-ctx-provider.test-d.tsx +++ b/types/test/lr-upload-ctx-provider.test-d.tsx @@ -15,7 +15,7 @@ import { UploadcareFile, UploadcareGroup } from '@uploadcare/upload-client'; const instance = new UploadCtxProvider(); -instance.addFileFromUrl('https://example.com/image.png'); +instance.api.addFileFromUrl('https://example.com/image.png'); instance.uploadCollection.size; instance.setOrAddState('fileId', 'uploading'); diff --git a/utils/validators/collection/validateCollectionUploadError.js b/utils/validators/collection/validateCollectionUploadError.js index 56fe2d9c0..38af5a693 100644 --- a/utils/validators/collection/validateCollectionUploadError.js +++ b/utils/validators/collection/validateCollectionUploadError.js @@ -1,11 +1,11 @@ // @ts-check /** @type {import('../../../abstract/ValidationManager.js').FuncCollectionValidator} */ -export const validateCollectionUploadError = (collection, block) => { +export const validateCollectionUploadError = (collection, api) => { if (collection.failedCount > 0) { return { type: 'SOME_FILES_HAS_ERRORS', - message: block.l10n('some-files-were-not-uploaded'), + message: api.l10n('some-files-were-not-uploaded'), }; } }; diff --git a/utils/validators/collection/validateMultiple.js b/utils/validators/collection/validateMultiple.js index 5e0dee893..dba19c313 100644 --- a/utils/validators/collection/validateMultiple.js +++ b/utils/validators/collection/validateMultiple.js @@ -1,13 +1,13 @@ //@ts-check /** @type {import('../../../abstract/ValidationManager.js').FuncCollectionValidator} */ -export const validateMultiple = (collection, block) => { +export const validateMultiple = (collection, api) => { const total = collection.totalCount; - const multipleMin = block.cfg.multiple ? block.cfg.multipleMin : 0; - const multipleMax = block.cfg.multiple ? block.cfg.multipleMax : 1; + const multipleMin = api.cfg.multiple ? api.cfg.multipleMin : 0; + const multipleMax = api.cfg.multiple ? api.cfg.multipleMax : 1; if (multipleMin && total < multipleMin) { - const message = block.l10n('files-count-limit-error-too-few', { + const message = api.l10n('files-count-limit-error-too-few', { min: multipleMin, max: multipleMax, total, @@ -25,7 +25,7 @@ export const validateMultiple = (collection, block) => { } if (multipleMax && total > multipleMax) { - const message = block.l10n('files-count-limit-error-too-many', { + const message = api.l10n('files-count-limit-error-too-many', { min: multipleMin, max: multipleMax, total, diff --git a/utils/validators/file/validateFileType.js b/utils/validators/file/validateFileType.js index c7ba28ff9..c47bcf581 100644 --- a/utils/validators/file/validateFileType.js +++ b/utils/validators/file/validateFileType.js @@ -2,9 +2,9 @@ import { IMAGE_ACCEPT_LIST, matchExtension, matchMimeType, mergeFileTypes } from '../../fileTypes.js'; /** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ -export const validateFileType = (outputEntry, block) => { - const imagesOnly = block.cfg.imgOnly; - const accept = block.cfg.accept; +export const validateFileType = (outputEntry, api) => { + const imagesOnly = api.cfg.imgOnly; + const accept = api.cfg.accept; const allowedFileTypes = mergeFileTypes([...(imagesOnly ? IMAGE_ACCEPT_LIST : []), accept]); if (!allowedFileTypes.length) return; @@ -23,7 +23,7 @@ export const validateFileType = (outputEntry, block) => { // Assume file type is not allowed if both mime and ext checks fail return { type: 'FORBIDDEN_FILE_TYPE', - message: block.l10n('file-type-not-allowed'), + message: api.l10n('file-type-not-allowed'), payload: { entry: outputEntry }, }; } diff --git a/utils/validators/file/validateIsImage.js b/utils/validators/file/validateIsImage.js index 0d9343ced..620092ea9 100644 --- a/utils/validators/file/validateIsImage.js +++ b/utils/validators/file/validateIsImage.js @@ -1,8 +1,8 @@ // @ts-check -/** @type import('../../../abstract/ValidationManager.js').FuncFileValidator */ -export const validateIsImage = (outputEntry, block) => { - const imagesOnly = block.cfg.imgOnly; +/** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ +export const validateIsImage = (outputEntry, api) => { + const imagesOnly = api.cfg.imgOnly; const isImage = outputEntry.isImage; if (!imagesOnly || isImage) { @@ -19,7 +19,7 @@ export const validateIsImage = (outputEntry, block) => { return { type: 'NOT_AN_IMAGE', - message: block.l10n('images-only-accepted'), + message: api.l10n('images-only-accepted'), payload: { entry: outputEntry }, }; }; diff --git a/utils/validators/file/validateMaxSizeLimit.js b/utils/validators/file/validateMaxSizeLimit.js index d8f9d73a0..57226d57a 100644 --- a/utils/validators/file/validateMaxSizeLimit.js +++ b/utils/validators/file/validateMaxSizeLimit.js @@ -2,13 +2,13 @@ import { prettyBytes } from '../../prettyBytes.js'; /** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ -export const validateMaxSizeLimit = (outputEntry, block) => { - const maxFileSize = block.cfg.maxLocalFileSizeBytes; +export const validateMaxSizeLimit = (outputEntry, api) => { + const maxFileSize = api.cfg.maxLocalFileSizeBytes; const fileSize = outputEntry.size; if (maxFileSize && fileSize && fileSize > maxFileSize) { return { type: 'FILE_SIZE_EXCEEDED', - message: block.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), + message: api.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), payload: { entry: outputEntry }, }; } diff --git a/utils/validators/file/validateUploadError.js b/utils/validators/file/validateUploadError.js index f9a6d9a0c..76687def3 100644 --- a/utils/validators/file/validateUploadError.js +++ b/utils/validators/file/validateUploadError.js @@ -2,11 +2,14 @@ import { NetworkError, UploadError } from '@uploadcare/upload-client'; /** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ -export const validateUploadError = (outputEntry, block) => { +export const validateUploadError = (outputEntry, api) => { const { internalId } = outputEntry; + // @ts-expect-error Use private API that is not exposed in the types + const internalEntry = api._uploadCollection.read(internalId); + /** @type {unknown} */ - const cause = block.uploadCollection.read(internalId)?.getValue('uploadError'); + const cause = internalEntry?.getValue('uploadError'); if (!cause) { return; }