Parcourir la source

Add uppy.passesRestrictions(file) and disallow selecting files that don’t pass restrictions in providers (#2602)

* add passesRestrictions(file) and utilize it in provider-views

* disallow selecting more than maxNumberOfFiles

* add restriction reason and include folders

* let’s leave folder out of this for now — otherwise not only you can’t select them, but navigate too

* disallow selecting if totalCurrentSelectionFileSize is over maxTotalFileSize

* handle maxNumberOfFiles not being set :see_no_evil:

* fix totalCurrentSelectionFileSize and handle no file.name

* support grid in restrictions

* refactor the restriction reason logic, rename to validateRestrictions, wrap icon in a container

* add uppyFileSizes :see_no_evil:

* get rid of canSelectMore in favor of validateRestrictions (file, files)

* handle shift key bulk selection

* remove unused props

* use infoTimeout
Artur Paikin il y a 4 ans
Parent
commit
3967e05fbc

+ 43 - 20
packages/@uppy/core/src/index.js

@@ -155,6 +155,7 @@ class Uppy {
     this.addFile = this.addFile.bind(this)
     this.removeFile = this.removeFile.bind(this)
     this.pauseResume = this.pauseResume.bind(this)
+    this.validateRestrictions = this.validateRestrictions.bind(this)
 
     // ___Why throttle at 500ms?
     //    - We must throttle at >250ms for superfocus in Dashboard to work well (because animation takes 0.25s, and we want to wait for all animations to be over before refocusing).
@@ -429,14 +430,24 @@ class Uppy {
   }
 
   /**
-   * Check if minNumberOfFiles restriction is reached before uploading.
+   * A public wrapper for _checkRestrictions — checks if a file passes a set of restrictions.
+   * For use in UI pluigins (like Providers), to disallow selecting files that won’t pass restrictions.
    *
-   * @private
+   * @param {object} file object to check
+   * @param {Array} [files] array to check maxNumberOfFiles and maxTotalFileSize
+   * @returns {object} { result: true/false, reason: why file didn’t pass restrictions }
    */
-  _checkMinNumberOfFiles (files) {
-    const { minNumberOfFiles } = this.opts.restrictions
-    if (Object.keys(files).length < minNumberOfFiles) {
-      throw new RestrictionError(`${this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })}`)
+  validateRestrictions (file, files) {
+    try {
+      this._checkRestrictions(file, files)
+      return {
+        result: true
+      }
+    } catch (err) {
+      return {
+        result: false,
+        reason: err.message
+      }
     }
   }
 
@@ -444,29 +455,29 @@ class Uppy {
    * Check if file passes a set of restrictions set in options: maxFileSize, minFileSize,
    * maxNumberOfFiles and allowedFileTypes.
    *
-   * @param {object} files Object of IDs → files already added
    * @param {object} file object to check
+   * @param {Array} [files] array to check maxNumberOfFiles and maxTotalFileSize
    * @private
    */
-  _checkRestrictions (files, file) {
+  _checkRestrictions (file, files = this.getFiles()) {
     const { maxFileSize, minFileSize, maxTotalFileSize, maxNumberOfFiles, allowedFileTypes } = this.opts.restrictions
 
     if (maxNumberOfFiles) {
-      if (Object.keys(files).length + 1 > maxNumberOfFiles) {
+      if (files.length + 1 > maxNumberOfFiles) {
         throw new RestrictionError(`${this.i18n('youCanOnlyUploadX', { smart_count: maxNumberOfFiles })}`)
       }
     }
 
     if (allowedFileTypes) {
       const isCorrectFileType = allowedFileTypes.some((type) => {
-        // is this is a mime-type
+        // check if this is a mime-type
         if (type.indexOf('/') > -1) {
           if (!file.type) return false
           return match(file.type.replace(/;.*?$/, ''), type)
         }
 
         // otherwise this is likely an extension
-        if (type[0] === '.') {
+        if (type[0] === '.' && file.extension) {
           return file.extension.toLowerCase() === type.substr(1).toLowerCase()
         }
         return false
@@ -479,11 +490,11 @@ class Uppy {
     }
 
     // We can't check maxTotalFileSize if the size is unknown.
-    if (maxTotalFileSize && file.data.size != null) {
+    if (maxTotalFileSize && file.size != null) {
       let totalFilesSize = 0
-      totalFilesSize += file.data.size
-      Object.keys(files).forEach((file) => {
-        totalFilesSize += files[file].data.size
+      totalFilesSize += file.size
+      files.forEach((file) => {
+        totalFilesSize += file.size
       })
       if (totalFilesSize > maxTotalFileSize) {
         throw new RestrictionError(this.i18n('exceedsSize2', {
@@ -494,8 +505,8 @@ class Uppy {
     }
 
     // We can't check maxFileSize if the size is unknown.
-    if (maxFileSize && file.data.size != null) {
-      if (file.data.size > maxFileSize) {
+    if (maxFileSize && file.size != null) {
+      if (file.size > maxFileSize) {
         throw new RestrictionError(this.i18n('exceedsSize2', {
           backwardsCompat: this.i18n('exceedsSize'),
           size: prettierBytes(maxFileSize)
@@ -504,8 +515,8 @@ class Uppy {
     }
 
     // We can't check minFileSize if the size is unknown.
-    if (minFileSize && file.data.size != null) {
-      if (file.data.size < minFileSize) {
+    if (minFileSize && file.size != null) {
+      if (file.size < minFileSize) {
         throw new RestrictionError(this.i18n('inferiorSize', {
           size: prettierBytes(minFileSize)
         }))
@@ -513,6 +524,18 @@ class Uppy {
     }
   }
 
+  /**
+   * Check if minNumberOfFiles restriction is reached before uploading.
+   *
+   * @private
+   */
+  _checkMinNumberOfFiles (files) {
+    const { minNumberOfFiles } = this.opts.restrictions
+    if (Object.keys(files).length < minNumberOfFiles) {
+      throw new RestrictionError(`${this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })}`)
+    }
+  }
+
   /**
    * Logs an error, sets Informer message, then throws the error.
    * Emits a 'restriction-failed' event if it’s a restriction error
@@ -630,7 +653,7 @@ class Uppy {
     }
 
     try {
-      this._checkRestrictions(files, newFile)
+      this._checkRestrictions(newFile)
     } catch (err) {
       this._showOrLogErrorAndThrow(err, { file: newFile })
     }

+ 42 - 0
packages/@uppy/core/src/index.test.js

@@ -1630,6 +1630,48 @@ describe('src/Core', () => {
       )
     })
 
+    it('should check if a file validateRestrictions', () => {
+      const core = new Core({
+        restrictions: {
+          minFileSize: 300000
+        }
+      })
+
+      const core2 = new Core({
+        restrictions: {
+          allowedFileTypes: ['image/png']
+        }
+      })
+
+      const newFile = {
+        source: 'jest',
+        name: 'foo1.jpg',
+        extension: 'jpg',
+        type: 'image/jpeg',
+        data: new File([sampleImage], { type: 'image/jpeg' }),
+        isFolder: false,
+        mimeType: 'image/jpeg',
+        modifiedDate: '2016-04-13T15:11:31.204Z',
+        size: 270733
+      }
+
+      const validateRestrictions1 = core.validateRestrictions(newFile)
+      const validateRestrictions2 = core2.validateRestrictions(newFile)
+
+      expect(validateRestrictions1).toMatchObject(
+        {
+          result: false,
+          reason: 'This file is smaller than the allowed size of 293 KB'
+        }
+      )
+      expect(validateRestrictions2).toMatchObject(
+        {
+          result: false,
+          reason: 'You can only upload: image/png'
+        }
+      )
+    })
+
     it('should emit `restriction-failed` event when some rule is violated', () => {
       const maxFileSize = 100
       const core = new Core({

+ 18 - 6
packages/@uppy/provider-views/src/Browser.js

@@ -5,15 +5,24 @@ const FooterActions = require('./FooterActions')
 const { h } = require('preact')
 
 const Browser = (props) => {
-  let filteredFolders = props.folders
-  let filteredFiles = props.files
+  const {
+    currentSelection,
+    folders,
+    files,
+    uppyFiles,
+    filterItems,
+    filterInput
+  } = props
 
-  if (props.filterInput !== '') {
-    filteredFolders = props.filterItems(props.folders)
-    filteredFiles = props.filterItems(props.files)
+  let filteredFolders = folders
+  let filteredFiles = files
+
+  if (filterInput !== '') {
+    filteredFolders = filterItems(folders)
+    filteredFiles = filterItems(files)
   }
 
-  const selected = props.currentSelection.length
+  const selected = currentSelection.length
 
   return (
     <div class={classNames('uppy-ProviderBrowser', `uppy-ProviderBrowser-viewType--${props.viewType}`)}>
@@ -40,6 +49,9 @@ const Browser = (props) => {
         showTitles={props.showTitles}
         i18n={props.i18n}
         viewType={props.viewType}
+        validateRestrictions={props.validateRestrictions}
+        uppyFiles={uppyFiles}
+        currentSelection={currentSelection}
       />
       {selected > 0 && <FooterActions selected={selected} {...props} />}
     </div>

+ 2 - 2
packages/@uppy/provider-views/src/Item/components/GridLi.js

@@ -3,17 +3,17 @@ const { h } = require('preact')
 // it could be a <li><button class="fake-checkbox"/> <button/></li>
 module.exports = (props) => {
   return (
-    <li class={props.className}>
+    <li class={props.className} title={props.isDisabled ? props.restrictionReason : null}>
       <div aria-hidden class={`uppy-ProviderBrowserItem-fakeCheckbox ${props.isChecked ? 'uppy-ProviderBrowserItem-fakeCheckbox--is-checked' : ''}`} />
       <button
         type="button"
         class="uppy-u-reset uppy-ProviderBrowserItem-inner"
         onclick={props.toggleCheckbox}
-
         role="option"
         aria-label={props.isChecked ? props.i18n('unselectFileNamed', { name: props.title }) : props.i18n('selectFileNamed', { name: props.title })}
         aria-selected={props.isChecked}
         aria-disabled={props.isDisabled}
+        disabled={props.isDisabled}
         data-uppy-super-focusable
       >
         {props.itemIconEl}

+ 9 - 4
packages/@uppy/provider-views/src/Item/components/ListLi.js

@@ -24,7 +24,7 @@ const getAriaLabelOfCheckbox = (props) => {
 //   + file name (selects file)
 module.exports = (props) => {
   return (
-    <li class={props.className}>
+    <li class={props.className} title={props.isDisabled ? props.restrictionReason : null}>
       <button
         type="button"
         class={`uppy-u-reset uppy-ProviderBrowserItem-fakeCheckbox ${props.isChecked ? 'uppy-ProviderBrowserItem-fakeCheckbox--is-checked' : ''}`}
@@ -35,13 +35,16 @@ module.exports = (props) => {
         aria-label={getAriaLabelOfCheckbox(props)}
         aria-selected={props.isChecked}
         aria-disabled={props.isDisabled}
+        disabled={props.isDisabled}
         data-uppy-super-focusable
       />
 
       {props.type === 'file' ? (
         // label for a checkbox
-        <label for={props.id} className="uppy-u-reset uppy-ProviderBrowserItem-inner">
-          {props.itemIconEl}
+        <label for={props.id} class="uppy-u-reset uppy-ProviderBrowserItem-inner">
+          <div class="uppy-ProviderBrowserItem-iconWrap">
+            {props.itemIconEl}
+          </div>
           {props.showTitles && props.title}
         </label>
       ) : (
@@ -52,7 +55,9 @@ module.exports = (props) => {
           onclick={props.handleFolderClick}
           aria-label={props.i18n('openFolderNamed', { name: props.title })}
         >
-          {props.itemIconEl}
+          <div class="uppy-ProviderBrowserItem-iconWrap">
+            {props.itemIconEl}
+          </div>
           {props.showTitles && <span>{props.title}</span>}
         </button>
       )}

+ 1 - 0
packages/@uppy/provider-views/src/Item/index.js

@@ -10,6 +10,7 @@ module.exports = (props) => {
   const className = classNames(
     'uppy-ProviderBrowserItem',
     { 'uppy-ProviderBrowserItem--selected': props.isChecked },
+    { 'uppy-ProviderBrowserItem--disabled': props.isDisabled },
     { 'uppy-ProviderBrowserItem--noPreview': itemIconString === 'video' }
   )
 

+ 17 - 8
packages/@uppy/provider-views/src/ItemList.js

@@ -1,4 +1,5 @@
 const { h } = require('preact')
+const remoteFileObjToLocal = require('@uppy/utils/lib/remoteFileObjToLocal')
 const Item = require('./Item/index')
 
 const getSharedProps = (fileOrFolder, props) => ({
@@ -27,21 +28,29 @@ module.exports = (props) => {
         // making <ul> not focusable for firefox
         tabindex="-1"
       >
-        {props.folders.map(folder =>
-          Item({
+        {props.folders.map(folder => {
+          return Item({
             ...getSharedProps(folder, props),
             type: 'folder',
             isDisabled: props.isChecked(folder) ? props.isChecked(folder).loading : false,
             handleFolderClick: () => props.handleFolderClick(folder)
           })
-        )}
-        {props.files.map(file =>
-          Item({
-            ...getSharedProps(file, props),
+        })}
+        {props.files.map(file => {
+          const validateRestrictions = props.validateRestrictions(
+            remoteFileObjToLocal(file),
+            [...props.uppyFiles, ...props.currentSelection]
+          )
+          const sharedProps = getSharedProps(file, props)
+          const restrictionReason = validateRestrictions.reason
+
+          return Item({
+            ...sharedProps,
             type: 'file',
-            isDisabled: false
+            isDisabled: !validateRestrictions.result && !sharedProps.isChecked,
+            restrictionReason: restrictionReason
           })
-        )}
+        })}
       </ul>
     </div>
   )

+ 3 - 1
packages/@uppy/provider-views/src/ProviderView/ProviderView.js

@@ -543,7 +543,9 @@ module.exports = class ProviderView {
       showFilter: targetViewOptions.showFilter,
       showBreadcrumbs: targetViewOptions.showBreadcrumbs,
       pluginIcon: this.plugin.icon,
-      i18n: this.plugin.uppy.i18n
+      i18n: this.plugin.uppy.i18n,
+      uppyFiles: this.plugin.uppy.getFiles(),
+      validateRestrictions: this.plugin.uppy.validateRestrictions
     })
 
     return (

+ 3 - 1
packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js

@@ -235,7 +235,9 @@ module.exports = class ProviderView {
       showFilter: targetViewOptions.showFilter,
       showBreadcrumbs: targetViewOptions.showBreadcrumbs,
       pluginIcon: this.plugin.icon,
-      i18n: this.plugin.uppy.i18n
+      i18n: this.plugin.uppy.i18n,
+      uppyFiles: this.plugin.uppy.getFiles(),
+      validateRestrictions: this.plugin.uppy.validateRestrictions
     })
 
     return (

+ 16 - 0
packages/@uppy/provider-views/src/SharedHandler.js

@@ -1,3 +1,5 @@
+const remoteFileObjToLocal = require('@uppy/utils/lib/remoteFileObjToLocal')
+
 module.exports = class SharedHandler {
   constructor (plugin) {
     this.plugin = plugin
@@ -42,6 +44,20 @@ module.exports = class SharedHandler {
       } else {
         currentSelection = items.slice(currentIndex, prevIndex + 1)
       }
+      // Check restrictions on each file in currentSelection,
+      // reduce it to only contain files that pass restrictions
+      currentSelection = currentSelection.reduce((reducedCurrentSelection, item) => {
+        const uppy = this.plugin.uppy
+        const validatedRestrictions = uppy.validateRestrictions(
+          remoteFileObjToLocal(item),
+          [...uppy.getFiles(), ...reducedCurrentSelection]
+        )
+        if (!validatedRestrictions.result) {
+          uppy.info({ message: validatedRestrictions.reason }, 'error', uppy.opts.infoTimeout)
+          return reducedCurrentSelection
+        }
+        return [...reducedCurrentSelection, item]
+      })
       this.plugin.setPluginState({ currentSelection })
       return
     }

+ 4 - 0
packages/@uppy/provider-views/src/style.scss

@@ -296,6 +296,10 @@
   [data-uppy-theme="dark"] & {
     background-color: $gray-900;
   }
+
+  &:focus {
+    outline: none;
+  }
 }
 
 .uppy-ProviderBrowserItem-inner {

+ 4 - 0
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss

@@ -41,6 +41,10 @@
     }
   }
 
+  li.uppy-ProviderBrowserItem--disabled {
+    opacity: 0.5;
+  }
+
   li.uppy-ProviderBrowserItem--noPreview {
     .uppy-ProviderBrowserItem-inner {
       background-color: rgba($gray-500, 0.2);

+ 15 - 1
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--list.scss

@@ -20,6 +20,10 @@
     }
   }
 
+  li.uppy-ProviderBrowserItem--disabled {
+    opacity: 0.6;
+  }
+
   // Checkbox
   .uppy-ProviderBrowserItem-fakeCheckbox {
     margin-right: 15px;
@@ -45,7 +49,7 @@
     [data-uppy-theme="dark"] &:focus {
       border-color: rgba($highlight--dark, 0.7);
       box-shadow: 0 0 0 3px rgba($highlight--dark, 0.2);
-    }
+    }   
   }
   // Checked: color the background, show the checkmark
   .uppy-ProviderBrowserItem-fakeCheckbox--is-checked {
@@ -64,6 +68,7 @@
     overflow: hidden;
     display: flex;
     align-items: center;
+
     // For better outline
     padding: 2px;
     &:focus {
@@ -81,4 +86,13 @@
       overflow: hidden;
     }
   }
+
+  .uppy-ProviderBrowserItem--disabled .uppy-ProviderBrowserItem-inner  {
+    cursor: default;
+  }
+
+  .uppy-ProviderBrowserItem-iconWrap {
+    width: 20px;
+    margin-right: 7px;
+  }
 }

+ 8 - 0
packages/@uppy/provider-views/src/style/uppy-ProviderBrowserItem-fakeCheckbox.scss

@@ -3,6 +3,10 @@
   cursor: pointer;
   flex-shrink: 0;
 
+  &:disabled {
+    cursor: default;
+  }
+
   // Checkmark icon
   &::after {
     content: '';
@@ -13,6 +17,10 @@
     transform: rotate(-45deg);
   }
 
+  &:disabled::after {
+    cursor: default;
+  }
+
   [data-uppy-theme="dark"] & {
     background-color: $gray-900;
     border-color: $gray-500;

+ 9 - 0
packages/@uppy/utils/src/remoteFileObjToLocal.js

@@ -0,0 +1,9 @@
+const getFileNameAndExtension = require('@uppy/utils/lib/getFileNameAndExtension')
+
+module.exports = function remoteFileObjToLocal (file) {
+  return {
+    ...file,
+    type: file.mimeType,
+    extension: file.name ? getFileNameAndExtension(file.name).extension : null
+  }
+}