diff --git a/abstract/CTX.js b/abstract/CTX.js index 02daf1011..fd349d240 100644 --- a/abstract/CTX.js +++ b/abstract/CTX.js @@ -27,7 +27,7 @@ export const uploaderBlockCtx = (fnCtx) => ({ '*uploadMetadata': null, '*uploadQueue': new Queue(1), '*uploadCollection': null, - /** @type {ReturnType[]} */ + /** @type {ReturnType[]} */ '*collectionErrors': [], /** @type {import('../types').OutputCollectionState | null} */ '*collectionState': null, diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 8079bd24d..fadb23975 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -2,7 +2,7 @@ import { ActivityBlock } from './ActivityBlock.js'; import { Data } from '@symbiotejs/symbiote'; -import { NetworkError, UploadError, uploadFileGroup } from '@uploadcare/upload-client'; +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'; @@ -10,10 +10,8 @@ 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 { buildCollectionFileError, buildOutputFileError } from '../utils/buildOutputError.js'; import { createCdnUrl, createCdnUrlModifiers } from '../utils/cdn-utils.js'; -import { IMAGE_ACCEPT_LIST, fileIsImage, matchExtension, matchMimeType, mergeFileTypes } from '../utils/fileTypes.js'; -import { prettyBytes } from '../utils/prettyBytes.js'; +import { IMAGE_ACCEPT_LIST, fileIsImage, mergeFileTypes } from '../utils/fileTypes.js'; import { stringToArray } from '../utils/stringToArray.js'; import { warnOnce } from '../utils/warnOnce.js'; import { uploaderBlockCtx } from './CTX.js'; @@ -22,6 +20,8 @@ import { buildOutputCollectionState } from './buildOutputCollectionState.js'; import { uploadEntrySchema } from './uploadEntrySchema.js'; import { parseCdnUrl } from '../utils/parseCdnUrl.js'; import { SecureUploadsManager } from './SecureUploadsManager.js'; +import { ValidationManager } from './ValidationManager.js'; + export class UploaderBlock extends ActivityBlock { couldBeCtxOwner = false; isCtxOwner = false; @@ -31,74 +31,6 @@ export class UploaderBlock extends ActivityBlock { /** @private */ __initialUploadMetadata = null; - /** - * @private - * @type {(( - * outputEntry: import('../types').OutputFileEntry, - * internalEntry?: import('./TypedData.js').TypedData, - * ) => undefined | ReturnType)[]} - */ - _fileValidators = [ - this._validateIsImage.bind(this), - this._validateFileType.bind(this), - this._validateMaxSizeLimit.bind(this), - this._validateUploadError.bind(this), - ]; - - /** - * @private - * @type {(( - * collection: TypedCollection, - * ) => - * | undefined - * | ReturnType - * | ReturnType[])[]} - */ - _collectionValidators = [ - (collection) => { - const total = collection.size; - const multipleMin = this.cfg.multiple ? this.cfg.multipleMin : 0; - const multipleMax = this.cfg.multiple ? this.cfg.multipleMax : 1; - if (multipleMin && total < multipleMin) { - const message = this.l10n('files-count-limit-error-too-few', { - min: multipleMin, - max: multipleMax, - total, - }); - return buildCollectionFileError({ - type: 'TOO_FEW_FILES', - message, - total, - min: multipleMin, - max: multipleMax, - }); - } - - if (multipleMax && total > multipleMax) { - const message = this.l10n('files-count-limit-error-too-many', { - min: multipleMin, - max: multipleMax, - total, - }); - return buildCollectionFileError({ - type: 'TOO_MANY_FILES', - message, - total, - min: multipleMin, - max: multipleMax, - }); - } - }, - (collection) => { - if (collection.items().some((id) => collection.readProp(id, 'errors').length > 0)) { - return buildCollectionFileError({ - type: 'SOME_FILES_HAS_ERRORS', - message: this.l10n('some-files-were-not-uploaded'), - }); - } - }, - ]; - /** * This is Public JS API method. Could be called before block initialization, so we need to delay state interactions * until block init. @@ -141,12 +73,21 @@ export class UploaderBlock extends ActivityBlock { }); this.$['*uploadCollection'] = uploadCollection; } + // + if (!this.has('*validationManager')) { + this.add('*validationManager', new ValidationManager(this)); + } if (!this.hasCtxOwner && this.couldBeCtxOwner) { this.initCtxOwner(); } } + /** @returns {ValidationManager | null} */ + get validationManager() { + return this.has('*validationManager') ? this.$['*validationManager'] : null; + } + destroyCtxCallback() { this._unobserveCollectionProperties?.(); this._unobserveCollection?.(); @@ -167,17 +108,6 @@ export class UploaderBlock extends ActivityBlock { this._handleCollectionPropertiesUpdate, ); - const runAllValidators = () => { - this._runFileValidators(); - this._runCollectionValidators(); - }; - - this.subConfigValue('maxLocalFileSizeBytes', runAllValidators); - this.subConfigValue('multipleMin', runAllValidators); - this.subConfigValue('multipleMax', runAllValidators); - this.subConfigValue('multiple', runAllValidators); - this.subConfigValue('imgOnly', runAllValidators); - this.subConfigValue('accept', runAllValidators); this.subConfigValue('maxConcurrentRequests', (value) => { this.$['*uploadQueue'].concurrency = Number(value) || 1; }); @@ -416,172 +346,6 @@ export class UploaderBlock extends ActivityBlock { return this.$['*uploadCollection']; } - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - */ - _validateFileType(outputEntry) { - const imagesOnly = this.cfg.imgOnly; - const accept = this.cfg.accept; - const allowedFileTypes = mergeFileTypes([...(imagesOnly ? IMAGE_ACCEPT_LIST : []), accept]); - if (!allowedFileTypes.length) return; - - const mimeType = outputEntry.mimeType; - const fileName = outputEntry.name; - - if (!mimeType || !fileName) { - // Skip client validation if mime type or file name are not available for some reasons - return; - } - - const mimeOk = matchMimeType(mimeType, allowedFileTypes); - const extOk = matchExtension(fileName, allowedFileTypes); - - if (!mimeOk && !extOk) { - // Assume file type is not allowed if both mime and ext checks fail - return buildOutputFileError({ - type: 'FORBIDDEN_FILE_TYPE', - message: this.l10n('file-type-not-allowed'), - entry: outputEntry, - }); - } - } - - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - */ - _validateMaxSizeLimit(outputEntry) { - const maxFileSize = this.cfg.maxLocalFileSizeBytes; - const fileSize = outputEntry.size; - if (maxFileSize && fileSize && fileSize > maxFileSize) { - return buildOutputFileError({ - type: 'FILE_SIZE_EXCEEDED', - message: this.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), - entry: outputEntry, - }); - } - } - - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - * @param {import('./TypedData.js').TypedData} [internalEntry] - */ - _validateUploadError(outputEntry, internalEntry) { - /** @type {unknown} */ - const cause = internalEntry?.getValue('uploadError'); - if (!cause) { - return; - } - - if (cause instanceof UploadError) { - return buildOutputFileError({ - type: 'UPLOAD_ERROR', - message: cause.message, - entry: outputEntry, - error: cause, - }); - } else if (cause instanceof NetworkError) { - return buildOutputFileError({ - type: 'NETWORK_ERROR', - message: cause.message, - entry: outputEntry, - error: cause, - }); - } else { - const error = cause instanceof Error ? cause : new Error('Unknown error', { cause }); - return buildOutputFileError({ - type: 'UNKNOWN_ERROR', - message: error.message, - entry: outputEntry, - error, - }); - } - } - - /** - * @private - * @param {import('../types').OutputFileEntry} outputEntry - */ - _validateIsImage(outputEntry) { - const imagesOnly = this.cfg.imgOnly; - const isImage = outputEntry.isImage; - if (!imagesOnly || isImage) { - return; - } - if (!outputEntry.fileInfo && outputEntry.externalUrl) { - // skip validation for not uploaded files with external url, cause we don't know if they're images or not - return; - } - if (!outputEntry.fileInfo && !outputEntry.mimeType) { - // skip validation for not uploaded files without mime-type, cause we don't know if they're images or not - return; - } - return buildOutputFileError({ - type: 'NOT_AN_IMAGE', - message: this.l10n('images-only-accepted'), - entry: outputEntry, - }); - } - - /** - * @private - * @param {import('./TypedData.js').TypedData} entry - */ - _runFileValidatorsForEntry(entry) { - const outputEntry = this.getOutputItem(entry.uid); - const errors = []; - - for (const validator of this._fileValidators) { - const error = validator(outputEntry, entry); - if (error) { - errors.push(error); - } - } - entry.setValue('errors', errors); - } - - /** - * @private - * @param {string[]} [entryIds] - */ - _runFileValidators(entryIds) { - const ids = entryIds ?? this.uploadCollection.items(); - for (const id of ids) { - const entry = this.uploadCollection.read(id); - entry && this._runFileValidatorsForEntry(entry); - } - } - - /** @private */ - _runCollectionValidators() { - const collection = this.uploadCollection; - const errors = []; - - for (const validator of this._collectionValidators) { - const errorOrErrors = validator(collection); - if (!errorOrErrors) { - continue; - } - if (Array.isArray(errorOrErrors)) { - errors.push(...errorOrErrors); - } else { - errors.push(errorOrErrors); - } - } - - this.$['*collectionErrors'] = errors; - - if (errors.length > 0) { - this.emit( - EventType.COMMON_UPLOAD_FAILED, - () => /** @type {import('../types').OutputCollectionState<'failed'>} */ (this.getOutputCollectionState()), - { debounce: true }, - ); - } - } - /** * @private * @param {import('../types').OutputCollectionState} collectionState @@ -630,8 +394,10 @@ export class UploaderBlock extends ActivityBlock { if (added.size || removed.size) { this.$['*groupInfo'] = null; } - this._runFileValidators(); - this._runCollectionValidators(); + if (this.validationManager) { + this.validationManager.runFileValidators(); + this.validationManager.runCollectionValidators(); + } for (const entry of added) { if (!entry.getValue('silent')) { @@ -681,7 +447,7 @@ 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 - this._runFileValidators(entriesToRunValidation); + if (this.validationManager) this.validationManager.runFileValidators(entriesToRunValidation); }); if (changeMap.uploadProgress) { diff --git a/abstract/ValidationManager.js b/abstract/ValidationManager.js new file mode 100644 index 000000000..045573bfa --- /dev/null +++ b/abstract/ValidationManager.js @@ -0,0 +1,162 @@ +// @ts-check +import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; +import { + validateIsImage, + validateFileType, + validateMaxSizeLimit, + validateUploadError, +} from '../utils/validators/file/index.js'; +import { validateMultiple, validateCollectionUploadError } from '../utils/validators/collection/index.js'; + +/** + * @typedef {( + * outputEntry: import('../types').OutputFileEntry, + * ctx: import('./UploaderBlock.js').UploaderBlock, + * ) => undefined | import('../types').OutputErrorFile} FuncFileValidator + */ + +/** + * @typedef {( + * collection: ReturnType< + * typeof import('./buildOutputCollectionState.js').buildOutputCollectionState< + * import('../types').OutputCollectionStatus + * > + * >, + * ctx: import('./UploaderBlock.js').UploaderBlock, + * ) => undefined | import('../types').OutputErrorCollection} FuncCollectionValidator + */ + +const LOGGER = { + file: 'File validator execution has failed', + collection: 'Collection validator execution has failed', + message: 'Missing message. We recommend adding message: value.', +}; + +export class ValidationManager { + /** + * @private + * @type {import('./UploaderBlock.js').UploaderBlock} + */ + _blockInstance; + + /** @type {FuncFileValidator[]} */ + _fileValidators = [validateIsImage, validateFileType, validateMaxSizeLimit, validateUploadError]; + + /** @type {FuncCollectionValidator[]} */ + _collectionValidators = [validateMultiple, validateCollectionUploadError]; + + /** @param {import('./UploaderBlock.js').UploaderBlock} blockInstance */ + constructor(blockInstance) { + this._blockInstance = blockInstance; + + this._uploadCollection = this._blockInstance.uploadCollection; + + const runAllValidators = () => { + this.runFileValidators(); + this.runCollectionValidators(); + }; + + this._blockInstance.subConfigValue('maxLocalFileSizeBytes', runAllValidators); + this._blockInstance.subConfigValue('multipleMin', runAllValidators); + this._blockInstance.subConfigValue('multipleMax', runAllValidators); + this._blockInstance.subConfigValue('multiple', runAllValidators); + this._blockInstance.subConfigValue('imgOnly', runAllValidators); + this._blockInstance.subConfigValue('accept', runAllValidators); + } + + /** @param {string[]} [entryIds] */ + runFileValidators(entryIds) { + const ids = entryIds ?? this._uploadCollection.items(); + for (const id of ids) { + const entry = this._uploadCollection.read(id); + if (entry) { + this._runFileValidatorsForEntry(entry); + } + } + } + + runCollectionValidators() { + const collection = this._blockInstance.getOutputCollectionState(); + const errors = []; + + for (const validator of [ + ...this._collectionValidators, + ...this._addCustomTypeToValidators(this._blockInstance.cfg.collectionValidators), + ]) { + try { + const errorOrErrors = validator(collection, this._blockInstance); + if (!errorOrErrors) { + continue; + } + if (errorOrErrors) { + errors.push(errorOrErrors); + + if (!errorOrErrors.message) { + console.warn(LOGGER.message); + } + } + } catch (error) { + console.warn(LOGGER.collection, error); + } + } + + this._blockInstance.$['*collectionErrors'] = errors; + + if (errors.length > 0) { + this._blockInstance.emit( + EventType.COMMON_UPLOAD_FAILED, + () => + /** @type {import('../types').OutputCollectionState<'failed'>} */ ( + this._blockInstance.getOutputCollectionState() + ), + { debounce: true }, + ); + } + } + + /** + * @private + * @param {import('./TypedData.js').TypedData} entry + */ + _runFileValidatorsForEntry(entry) { + const outputEntry = this._blockInstance.getOutputItem(entry.uid); + const errors = []; + + for (const validator of [ + ...this._fileValidators, + ...this._addCustomTypeToValidators(this._blockInstance.cfg.fileValidators), + ]) { + try { + const error = validator(outputEntry, this._blockInstance); + if (!error) { + continue; + } + if (error) { + errors.push(error); + + if (!error.message) { + console.warn(LOGGER.message); + } + } + } catch (error) { + console.warn(LOGGER.file, error); + } + } + entry.setValue('errors', errors); + } + + /** + * @template T + * @param {T[]} validators + * @returns {T[]} + */ + _addCustomTypeToValidators(validators) { + // @ts-ignore + return validators.map((fn) => (...args) => { + // @ts-ignore + const result = fn(...args); + + return result ? { ...result, ...{ type: 'CUSTOM_ERROR' } } : undefined; + }); + } +} diff --git a/abstract/buildOutputCollectionState.js b/abstract/buildOutputCollectionState.js index 798f5b4aa..f49b5ccba 100644 --- a/abstract/buildOutputCollectionState.js +++ b/abstract/buildOutputCollectionState.js @@ -43,7 +43,7 @@ export function buildOutputCollectionState(uploaderBlock) { progress: () => { return uploaderBlock.$['*commonProgress']; }, - /** @returns {ReturnType[]} */ + /** @returns {ReturnType[]} */ errors: () => { return uploaderBlock.$['*collectionErrors']; }, diff --git a/blocks/Config/Config.js b/blocks/Config/Config.js index f9c1d0a74..e3585dd81 100644 --- a/blocks/Config/Config.js +++ b/blocks/Config/Config.js @@ -19,6 +19,8 @@ const allConfigKeys = /** @type {(keyof import('../../types').ConfigType)[]} */ * 'secureUploadsSignatureResolver', * 'secureDeliveryProxyUrlResolver', * 'iconHrefResolver', + * 'fileValidators', + * 'collectionValidators' * ]} */ export const complexConfigKeys = [ @@ -27,6 +29,8 @@ export const complexConfigKeys = [ 'secureUploadsSignatureResolver', 'secureDeliveryProxyUrlResolver', 'iconHrefResolver', + 'fileValidators', + 'collectionValidators' ]; /** @type {(key: keyof import('../../types').ConfigType) => key is keyof import('../../types').ConfigComplexType} */ diff --git a/blocks/Config/initialConfig.js b/blocks/Config/initialConfig.js index ae5216ed4..02cbc9024 100644 --- a/blocks/Config/initialConfig.js +++ b/blocks/Config/initialConfig.js @@ -64,4 +64,6 @@ export const initialConfig = { secureUploadsSignatureResolver: null, secureDeliveryProxyUrlResolver: null, iconHrefResolver: null, + fileValidators: [], + collectionValidators: [], }; diff --git a/blocks/Config/normalizeConfigValue.js b/blocks/Config/normalizeConfigValue.js index 34e20eb90..ff90d7e80 100644 --- a/blocks/Config/normalizeConfigValue.js +++ b/blocks/Config/normalizeConfigValue.js @@ -74,6 +74,19 @@ const asFunction = (value) => { throw new Error('Invalid value. Must be a function.'); }; +/** + * @template {Function[]} T + * @param {unknown} value + * @returns {T} + */ +const asArray = (value) => { + if (Array.isArray(value)) { + return /** @type {T} */ (value); + } + + throw new Error('Must be an array.'); +}; + /** * @type {{ * [Key in keyof import('../../types').ConfigType]: ( @@ -140,6 +153,8 @@ const mapping = { secureDeliveryProxyUrlResolver: /** @type {typeof asFunction} */ (asFunction), iconHrefResolver: /** @type {typeof asFunction} */ (asFunction), + fileValidators: /** @type {typeof asArray} */ (asArray), + collectionValidators: /** @type {typeof asArray} */ (asArray), }; /** diff --git a/blocks/UploadList/UploadList.js b/blocks/UploadList/UploadList.js index 0dd653046..57daa1345 100644 --- a/blocks/UploadList/UploadList.js +++ b/blocks/UploadList/UploadList.js @@ -79,7 +79,7 @@ export class UploadList extends UploaderBlock { ); const tooMany = collectionState.errors.some((err) => err.type === 'TOO_MANY_FILES'); const exact = collectionState.totalCount === (this.cfg.multiple ? this.cfg.multipleMax : 1); - const validationOk = summary.failed === 0; + const validationOk = summary.failed === 0 && collectionState.errors.length === 0; let uploadBtnVisible = false; let allDone = false; let doneBtnEnabled = false; diff --git a/demo/raw-regular.html b/demo/raw-regular.html index 12432a960..e5f6c4a84 100644 --- a/demo/raw-regular.html +++ b/demo/raw-regular.html @@ -17,6 +17,7 @@ } } + + + + + + + + + + \ No newline at end of file diff --git a/types/exported.d.ts b/types/exported.d.ts index dc11650a6..ba034a1d6 100644 --- a/types/exported.d.ts +++ b/types/exported.d.ts @@ -1,5 +1,8 @@ -import { LocaleDefinition } from '../abstract/localeRegistry'; -import { complexConfigKeys } from '../blocks/Config/Config'; +import type { LocaleDefinition } from '../abstract/localeRegistry'; +import type { complexConfigKeys } from '../blocks/Config/Config'; +import type { FuncFileValidator, FuncCollectionValidator } from '../abstract/ValidationManager'; + +export type { FuncFileValidator, FuncCollectionValidator } from '../abstract/ValidationManager'; export type UploadError = import('@uploadcare/upload-client').UploadError; export type UploadcareFile = import('@uploadcare/upload-client').UploadcareFile; @@ -15,6 +18,8 @@ export type SecureDeliveryProxyUrlResolver = ( export type SecureUploadsSignatureAndExpire = { secureSignature: string; secureExpire: string }; export type SecureUploadsSignatureResolver = () => Promise; export type IconHrefResolver = (iconName: string) => string; +export type FileValidators = FuncFileValidator[]; +export type CollectionValidators = FuncCollectionValidator[]; export type ConfigType = { pubkey: string; @@ -60,14 +65,17 @@ export type ConfigType = { userAgentIntegration: string; debug: boolean; localeName: string; - secureUploadsExpireThreshold: number; - + secureUploadsExpireThreshold: number; + // Complex types metadata: Metadata | MetadataCallback | null; localeDefinitionOverride: LocaleDefinitionOverride | null; secureUploadsSignatureResolver: SecureUploadsSignatureResolver | null; secureDeliveryProxyUrlResolver: SecureDeliveryProxyUrlResolver | null; iconHrefResolver: IconHrefResolver | null; + + fileValidators: FileValidators; + collectionValidators: CollectionValidators; }; export type ConfigComplexType = Pick; export type ConfigPlainType = Omit; @@ -75,8 +83,8 @@ export type ConfigAttributesType = KebabCaseKeys & LowerCaseKey export type KebabCase = S extends `${infer C}${infer T}` ? T extends Uncapitalize - ? `${Uncapitalize}${KebabCase}` - : `${Uncapitalize}-${KebabCase}` + ? `${Uncapitalize}${KebabCase}` + : `${Uncapitalize}-${KebabCase}` : S; export type KebabCaseKeys> = { [Key in keyof T as KebabCase]: T[Key] }; export type LowerCase = Lowercase; @@ -84,14 +92,17 @@ export type LowerCaseKeys> = { [Key in keyof T export type OutputFileStatus = 'idle' | 'uploading' | 'success' | 'failed' | 'removed'; -export type OutputFileErrorType = +export type OutputCustomErrorType = 'CUSTOM_ERROR' + +export type OutputFileErrorType = OutputCustomErrorType | 'NOT_AN_IMAGE' | 'FORBIDDEN_FILE_TYPE' | 'FILE_SIZE_EXCEEDED' | 'UPLOAD_ERROR' | 'NETWORK_ERROR' | 'UNKNOWN_ERROR'; -export type OutputCollectionErrorType = 'SOME_FILES_HAS_ERRORS' | 'TOO_MANY_FILES' | 'TOO_FEW_FILES'; + +export type OutputCollectionErrorType = OutputCustomErrorType | 'SOME_FILES_HAS_ERRORS' | 'TOO_MANY_FILES' | 'TOO_FEW_FILES'; export type OutputFileErrorPayload = { entry: OutputFileEntry; @@ -122,15 +133,25 @@ export type OutputErrorTypePayload = { UNKNOWN_ERROR: OutputFileErrorPayload & { error?: Error; }; + CUSTOM_ERROR: Record; }; export type OutputError = - T extends keyof OutputErrorTypePayload - ? { - type: T; - message: string; - } & OutputErrorTypePayload[T] - : never; + T extends OutputCustomErrorType + ? { + type?: T; + message: string; + payload?: OutputErrorTypePayload[T]; + } + : T extends keyof OutputErrorTypePayload ? { + type: T; + message: string; + payload?: OutputErrorTypePayload[T]; + } : never + +export type OutputErrorFile = OutputError + +export type OutputErrorCollection = OutputError export type OutputFileEntry = { status: TStatus; @@ -147,7 +168,7 @@ export type OutputFileEntry uploadProgress: number; fullPath: string | null; } & ( - | { + | { status: 'success'; fileInfo: UploadcareFile; uuid: string; @@ -159,7 +180,7 @@ export type OutputFileEntry isRemoved: false; errors: []; } - | { + | { status: 'failed'; fileInfo: UploadcareFile | null; uuid: string | null; @@ -171,7 +192,7 @@ export type OutputFileEntry isRemoved: false; errors: OutputError[]; } - | { + | { status: 'uploading'; fileInfo: null; uuid: null; @@ -183,7 +204,7 @@ export type OutputFileEntry isRemoved: false; errors: []; } - | { + | { status: 'removed'; fileInfo: UploadcareFile | null; uuid: string | null; @@ -195,7 +216,7 @@ export type OutputFileEntry isRemoved: true; errors: OutputError[]; } - | { + | { status: 'idle'; fileInfo: null; uuid: null; @@ -207,7 +228,7 @@ export type OutputFileEntry isRemoved: false; errors: []; } -); + ); export type OutputCollectionStatus = 'idle' | 'uploading' | 'success' | 'failed'; @@ -231,43 +252,43 @@ export type OutputCollectionState< } & (TGroupFlag extends 'has-group' ? { group: UploadcareGroup } : TGroupFlag extends 'maybe-has-group' - ? { group: UploadcareGroup | null } - : never) & + ? { group: UploadcareGroup | null } + : never) & ( | { - status: 'idle'; - isFailed: false; - isUploading: false; - isSuccess: false; - errors: []; - allEntries: OutputFileEntry<'idle' | 'success'>[]; - } + status: 'idle'; + isFailed: false; + isUploading: false; + isSuccess: false; + errors: []; + allEntries: OutputFileEntry<'idle' | 'success'>[]; + } | { - status: 'uploading'; - isFailed: false; - isUploading: true; - isSuccess: false; - errors: []; - allEntries: OutputFileEntry[]; - } + status: 'uploading'; + isFailed: false; + isUploading: true; + isSuccess: false; + errors: []; + allEntries: OutputFileEntry[]; + } | { - status: 'success'; - isFailed: false; - isUploading: false; - isSuccess: true; - errors: []; - allEntries: OutputFileEntry<'success'>[]; - } + status: 'success'; + isFailed: false; + isUploading: false; + isSuccess: true; + errors: []; + allEntries: OutputFileEntry<'success'>[]; + } | { - status: 'failed'; - isFailed: true; - isUploading: false; - isSuccess: false; - errors: OutputError[]; - allEntries: OutputFileEntry[]; - } + status: 'failed'; + isFailed: true; + isUploading: false; + isSuccess: false; + errors: OutputError[]; + allEntries: OutputFileEntry[]; + } ); export { EventType, EventPayload } from '../blocks/UploadCtxProvider/EventEmitter'; -export {}; +export { }; diff --git a/types/test/lr-config.test-d.tsx b/types/test/lr-config.test-d.tsx index b437476cf..f5e8ca914 100644 --- a/types/test/lr-config.test-d.tsx +++ b/types/test/lr-config.test-d.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { expectType } from 'tsd'; import '../jsx.js'; -import { OutputFileEntry } from '../index.js'; +import { OutputFileEntry, FuncCollectionValidator, FuncFileValidator } from '../index.js'; // @ts-expect-error untyped props () => ; @@ -66,3 +66,25 @@ import { OutputFileEntry } from '../index.js'; }; } }; + + + +// allow to pass validators +() => { + const ref = React.useRef | null>(null); + if (ref.current) { + const config = ref.current; + + const maxSize: FuncFileValidator = (outputEntry, block) => ({ + message: block.l10n('images-only-accepted'), + payload: { entry: outputEntry }, + }) + + const maxCollection: FuncCollectionValidator = (collection, block) => ({ + message: block.l10n('some-files-were-not-uploaded'), + }) + + config.fileValidators = [maxSize] + config.collectionValidators = [maxCollection] + } +}; diff --git a/utils/buildOutputError.js b/utils/buildOutputError.js deleted file mode 100644 index 836440b2c..000000000 --- a/utils/buildOutputError.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @template T - * @param {import('../types').OutputError} options - */ -const buildOutputError = ({ type, message, ...payload }) => ({ - type, - message, - ...payload, -}); - -/** @type {typeof buildOutputError} */ -export const buildOutputFileError = buildOutputError; - -/** @type {typeof buildOutputError} */ -export const buildCollectionFileError = buildOutputError; diff --git a/utils/validators/collection/index.js b/utils/validators/collection/index.js new file mode 100644 index 000000000..406e8e62b --- /dev/null +++ b/utils/validators/collection/index.js @@ -0,0 +1,2 @@ +export { validateCollectionUploadError } from './validateCollectionUploadError.js'; +export { validateMultiple } from './validateMultiple.js'; diff --git a/utils/validators/collection/validateCollectionUploadError.js b/utils/validators/collection/validateCollectionUploadError.js new file mode 100644 index 000000000..56fe2d9c0 --- /dev/null +++ b/utils/validators/collection/validateCollectionUploadError.js @@ -0,0 +1,11 @@ +// @ts-check + +/** @type {import('../../../abstract/ValidationManager.js').FuncCollectionValidator} */ +export const validateCollectionUploadError = (collection, block) => { + if (collection.failedCount > 0) { + return { + type: 'SOME_FILES_HAS_ERRORS', + message: block.l10n('some-files-were-not-uploaded'), + }; + } +}; diff --git a/utils/validators/collection/validateMultiple.js b/utils/validators/collection/validateMultiple.js new file mode 100644 index 000000000..5e0dee893 --- /dev/null +++ b/utils/validators/collection/validateMultiple.js @@ -0,0 +1,43 @@ +//@ts-check + +/** @type {import('../../../abstract/ValidationManager.js').FuncCollectionValidator} */ +export const validateMultiple = (collection, block) => { + const total = collection.totalCount; + const multipleMin = block.cfg.multiple ? block.cfg.multipleMin : 0; + const multipleMax = block.cfg.multiple ? block.cfg.multipleMax : 1; + + if (multipleMin && total < multipleMin) { + const message = block.l10n('files-count-limit-error-too-few', { + min: multipleMin, + max: multipleMax, + total, + }); + + return { + type: 'TOO_FEW_FILES', + message, + payload: { + total, + min: multipleMin, + max: multipleMax, + }, + }; + } + + if (multipleMax && total > multipleMax) { + const message = block.l10n('files-count-limit-error-too-many', { + min: multipleMin, + max: multipleMax, + total, + }); + return { + type: 'TOO_MANY_FILES', + message, + payload: { + total, + min: multipleMin, + max: multipleMax, + }, + }; + } +}; diff --git a/utils/validators/file/index.js b/utils/validators/file/index.js new file mode 100644 index 000000000..ed1b3602d --- /dev/null +++ b/utils/validators/file/index.js @@ -0,0 +1,4 @@ +export { validateIsImage } from './validateIsImage.js'; +export { validateFileType } from './validateFileType.js'; +export { validateMaxSizeLimit } from './validateMaxSizeLimit.js'; +export { validateUploadError } from './validateUploadError.js'; diff --git a/utils/validators/file/validateFileType.js b/utils/validators/file/validateFileType.js new file mode 100644 index 000000000..c7ba28ff9 --- /dev/null +++ b/utils/validators/file/validateFileType.js @@ -0,0 +1,30 @@ +// @ts-check +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; + const allowedFileTypes = mergeFileTypes([...(imagesOnly ? IMAGE_ACCEPT_LIST : []), accept]); + if (!allowedFileTypes.length) return; + + const mimeType = outputEntry.mimeType; + const fileName = outputEntry.name; + + if (!mimeType || !fileName) { + // Skip client validation if mime type or file name are not available for some reasons + return; + } + + const mimeOk = matchMimeType(mimeType, allowedFileTypes); + const extOk = matchExtension(fileName, allowedFileTypes); + + if (!mimeOk && !extOk) { + // 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'), + payload: { entry: outputEntry }, + }; + } +}; diff --git a/utils/validators/file/validateIsImage.js b/utils/validators/file/validateIsImage.js new file mode 100644 index 000000000..0d9343ced --- /dev/null +++ b/utils/validators/file/validateIsImage.js @@ -0,0 +1,25 @@ +// @ts-check + +/** @type import('../../../abstract/ValidationManager.js').FuncFileValidator */ +export const validateIsImage = (outputEntry, block) => { + const imagesOnly = block.cfg.imgOnly; + const isImage = outputEntry.isImage; + + if (!imagesOnly || isImage) { + return; + } + if (!outputEntry.fileInfo && outputEntry.externalUrl) { + // skip validation for not uploaded files with external url, cause we don't know if they're images or not + return; + } + if (!outputEntry.fileInfo && !outputEntry.mimeType) { + // skip validation for not uploaded files without mime-type, cause we don't know if they're images or not + return; + } + + return { + type: 'NOT_AN_IMAGE', + message: block.l10n('images-only-accepted'), + payload: { entry: outputEntry }, + }; +}; diff --git a/utils/validators/file/validateMaxSizeLimit.js b/utils/validators/file/validateMaxSizeLimit.js new file mode 100644 index 000000000..d8f9d73a0 --- /dev/null +++ b/utils/validators/file/validateMaxSizeLimit.js @@ -0,0 +1,15 @@ +// @ts-check +import { prettyBytes } from '../../prettyBytes.js'; + +/** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ +export const validateMaxSizeLimit = (outputEntry, block) => { + const maxFileSize = block.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) }), + payload: { entry: outputEntry }, + }; + } +}; diff --git a/utils/validators/file/validateUploadError.js b/utils/validators/file/validateUploadError.js new file mode 100644 index 000000000..f9a6d9a0c --- /dev/null +++ b/utils/validators/file/validateUploadError.js @@ -0,0 +1,45 @@ +// @ts-check +import { NetworkError, UploadError } from '@uploadcare/upload-client'; + +/** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ +export const validateUploadError = (outputEntry, block) => { + const { internalId } = outputEntry; + + /** @type {unknown} */ + const cause = block.uploadCollection.read(internalId)?.getValue('uploadError'); + if (!cause) { + return; + } + + if (cause instanceof UploadError) { + return { + type: 'UPLOAD_ERROR', + message: cause.message, + payload: { + entry: outputEntry, + error: cause, + }, + }; + } + + if (cause instanceof NetworkError) { + return { + type: 'NETWORK_ERROR', + message: cause.message, + payload: { + entry: outputEntry, + error: cause, + }, + }; + } + + const error = cause instanceof Error ? cause : new Error('Unknown error', { cause }); + return { + type: 'UNKNOWN_ERROR', + message: error.message, + payload: { + entry: outputEntry, + error, + }, + }; +};