Browse Source

List virtualization and lazy thumbnails (#2161)

Renée Kooi 5 years ago
parent
commit
0cc2c360cf

+ 1 - 0
CHANGELOG.md

@@ -43,6 +43,7 @@ PRs are welcome! Please do open an issue to discuss first if it's a big feature,
 - [ ] uploaders: consider not showing progress updates from the server after an upload’s been paused. Perhaps the button can be disabled and say `Pausing..` until Companion has actually stopped transmitting updates (@arturi, @ifedapoolarewaju)
 - [ ] website: add an example of a mini UI that features drop & progress (may involve a `mini: true` options for dashboard, may involve drop+progress) (@arturi)
 - [ ] xhr: allow sending custom headers per file (as proposed in #785)
+- [ ] dashboard: focus jumps weirdly if you remove a file https://github.com/transloadit/uppy/pull/2161#issuecomment-613565486
 
 ## 2.0
 

+ 14 - 1
packages/@uppy/dashboard/src/components/Dashboard.js

@@ -48,6 +48,16 @@ module.exports = function Dashboard (props) {
     'uppy-Dashboard--isInnerWrapVisible': props.areInsidesReadyToBeVisible
   })
 
+  // Important: keep these in sync with the percent width values in `src/components/FileItem/index.scss`.
+  let itemsPerRow = 1 // mobile
+  if (props.containerWidth > WIDTH_XL) {
+    itemsPerRow = 5
+  } else if (props.containerWidth > WIDTH_LG) {
+    itemsPerRow = 4
+  } else if (props.containerWidth > WIDTH_MD) {
+    itemsPerRow = 3
+  }
+
   const showFileList = props.showSelectedFiles && !noFiles
 
   return (
@@ -99,7 +109,10 @@ module.exports = function Dashboard (props) {
           {showFileList && <PanelTopBar {...props} />}
 
           {showFileList ? (
-            <FileList {...props} />
+            <FileList
+              {...props}
+              itemsPerRow={itemsPerRow}
+            />
           ) : (
             <AddFiles {...props} isSizeMD={isSizeMD} />
           )}

+ 64 - 27
packages/@uppy/dashboard/src/components/FileItem/Buttons/index.js

@@ -3,33 +3,44 @@ const copyToClipboard = require('../../../utils/copyToClipboard')
 
 const { iconPencil, iconCross, iconCopyLink } = require('../../icons')
 
-const renderEditButton = (props) => (
-  !props.uploadInProgressOrComplete &&
-  props.metaFields &&
-  props.metaFields.length > 0 &&
-    <button
-      class="uppy-u-reset uppy-DashboardItem-action uppy-DashboardItem-action--edit"
-      type="button"
-      aria-label={props.i18n('editFile') + ' ' + props.file.meta.name}
-      title={props.i18n('editFile')}
-      onclick={(e) => props.toggleFileCard(props.file.id)}
-    >
-      {iconPencil()}
-    </button>
-)
+function EditButton ({
+  file,
+  uploadInProgressOrComplete,
+  metaFields,
+  i18n,
+  onClick
+}) {
+  if (!uploadInProgressOrComplete &&
+      metaFields &&
+      metaFields.length > 0) {
+    return (
+      <button
+        class="uppy-u-reset uppy-DashboardItem-action uppy-DashboardItem-action--edit"
+        type="button"
+        aria-label={i18n('editFile') + ' ' + file.meta.name}
+        title={i18n('editFile')}
+        onclick={() => onClick()}
+      >
+        {iconPencil()}
+      </button>
+    )
+  }
+  return null
+}
 
-const renderRemoveButton = (props) => (
-  props.showRemoveButton &&
+function RemoveButton ({ i18n, onClick }) {
+  return (
     <button
       class="uppy-u-reset uppy-DashboardItem-action uppy-DashboardItem-action--remove"
       type="button"
-      aria-label={props.i18n('removeFile')}
-      title={props.i18n('removeFile')}
-      onclick={() => props.removeFile(props.file.id)}
+      aria-label={i18n('removeFile')}
+      title={i18n('removeFile')}
+      onclick={() => onClick()}
     >
       {iconCross()}
     </button>
-)
+  )
+}
 
 const copyLinkToClipboard = (event, props) =>
   copyToClipboard(props.file.uploadURL, props.i18n('copyLinkToClipboardFallback'))
@@ -41,9 +52,8 @@ const copyLinkToClipboard = (event, props) =>
     // avoid losing focus
     .then(() => event.target.focus({ preventScroll: true }))
 
-const renderCopyLinkButton = (props) => (
-  props.showLinkToFileUploadResult &&
-  props.file.uploadURL &&
+function CopyLinkButton (props) {
+  return (
     <button
       class="uppy-u-reset uppy-DashboardItem-action uppy-DashboardItem-action--copyLink"
       type="button"
@@ -53,14 +63,41 @@ const renderCopyLinkButton = (props) => (
     >
       {iconCopyLink()}
     </button>
-)
+  )
+}
 
 module.exports = function Buttons (props) {
+  const {
+    file,
+    uploadInProgressOrComplete,
+    metaFields,
+    showLinkToFileUploadResult,
+    showRemoveButton,
+    i18n,
+    removeFile,
+    toggleFileCard
+  } = props
+
   return (
     <div className="uppy-DashboardItem-actionWrapper">
-      {renderEditButton(props)}
-      {renderCopyLinkButton(props)}
-      {renderRemoveButton(props)}
+      <EditButton
+        i18n={i18n}
+        file={file}
+        uploadInProgressOrComplete={uploadInProgressOrComplete}
+        metaFields={metaFields}
+        onClick={() => toggleFileCard(file.id)}
+      />
+      {showLinkToFileUploadResult && file.uploadURL ? (
+        <CopyLinkButton i18n={i18n} />
+      ) : null}
+      {showRemoveButton ? (
+        <RemoveButton
+          i18n={i18n}
+          info={props.info}
+          log={props.log}
+          onClick={() => removeFile(file.id)}
+        />
+      ) : null}
     </div>
   )
 }

+ 31 - 3
packages/@uppy/dashboard/src/components/FileItem/index.js

@@ -11,6 +11,20 @@ module.exports = class FileItem extends Component {
     return !shallowEqual(this.props, nextProps)
   }
 
+  componentDidMount () {
+    const file = this.props.file
+    if (!file.preview) {
+      this.props.handleRequestThumbnail(file)
+    }
+  }
+
+  componentWillUnmount () {
+    const file = this.props.file
+    if (!file.preview) {
+      this.props.handleCancelThumbnail(file)
+    }
+  }
+
   render () {
     const file = this.props.file
 
@@ -38,17 +52,31 @@ module.exports = class FileItem extends Component {
     })
 
     return (
-      <li class={dashboardItemClass} id={`uppy_${file.id}`}>
+      <div
+        class={dashboardItemClass}
+        id={`uppy_${file.id}`}
+        role={this.props.role}
+      >
         <div class="uppy-DashboardItem-preview">
           <FilePreviewAndLink
             file={file}
             showLinkToFileUploadResult={this.props.showLinkToFileUploadResult}
           />
           <FileProgress
-            {...this.props}
             file={file}
             error={error}
             isUploaded={isUploaded}
+
+            hideRetryButton={this.props.hideRetryButton}
+            hidePauseResumeCancelButtons={this.props.hidePauseResumeCancelButtons}
+
+            resumableUploads={this.props.resumableUploads}
+            individualCancellation={this.props.individualCancellation}
+
+            pauseUpload={this.props.pauseUpload}
+            cancelUpload={this.props.cancelUpload}
+            retryUpload={this.props.retryUpload}
+            i18n={this.props.i18n}
           />
         </div>
 
@@ -76,7 +104,7 @@ module.exports = class FileItem extends Component {
             info={this.props.info}
           />
         </div>
-      </li>
+      </div>
     )
   }
 }

+ 4 - 1
packages/@uppy/dashboard/src/components/FileItem/index.scss

@@ -9,7 +9,7 @@
   border-bottom: 1px solid $gray-200;
   padding: 10px;
   padding-right: 0;
-  
+
   [data-uppy-theme="dark"] & {
     border-bottom: 1px solid $gray-800;
   }
@@ -24,6 +24,7 @@
     float: left;
     margin: 5px $rl-margin;
     padding: 0;
+    /* When changing width: also update `itemsPerRow` values in `src/components/Dashboard.js`. */
     width: calc(33.333% - #{$rl-margin} - #{$rl-margin});
     height: 215px;
     border-bottom: 0;
@@ -31,11 +32,13 @@
 
   .uppy-size--lg & {
     margin: 5px $rl-margin;
+    /* When changing width: also update `itemsPerRow` values in `src/components/Dashboard.js`. */
     width: calc(25% - #{$rl-margin} - #{$rl-margin});
     height: 190px;
   }
 
   .uppy-size--xl & {
+    /* When changing width: also update `itemsPerRow` values in `src/components/Dashboard.js`. */
     width: calc(20% - #{$rl-margin} - #{$rl-margin});
     height: 210px;
   }

+ 53 - 14
packages/@uppy/dashboard/src/components/FileList.js

@@ -1,12 +1,37 @@
 const FileItem = require('./FileItem/index.js')
+const VirtualList = require('./VirtualList')
 const classNames = require('classnames')
 const { h } = require('preact')
 
-module.exports = (props) => {
-  const dashboardFilesClass = classNames({
-    'uppy-Dashboard-files': true,
-    'uppy-Dashboard-files--noFiles': props.totalFileCount === 0
+function chunks (list, size) {
+  const chunked = []
+  let currentChunk = []
+  list.forEach((item, i) => {
+    if (currentChunk.length < size) {
+      currentChunk.push(item)
+    } else {
+      chunked.push(currentChunk)
+      currentChunk = [item]
+    }
   })
+  if (currentChunk.length) chunked.push(currentChunk)
+  return chunked
+}
+
+module.exports = (props) => {
+  const noFiles = props.totalFileCount === 0
+  const dashboardFilesClass = classNames(
+    'uppy-Dashboard-files',
+    { 'uppy-Dashboard-files--noFiles': noFiles }
+  )
+
+  // It's not great that this is hardcoded!
+  // It's ESPECIALLY not great that this is checking against `itemsPerRow`!
+  const rowHeight = props.itemsPerRow === 1
+    // Mobile
+    ? 71
+    // 190px height + 2 * 5px margin
+    : 200
 
   const fileProps = {
     // FIXME This is confusing, it's actually the Dashboard's plugin ID
@@ -32,22 +57,36 @@ module.exports = (props) => {
     cancelUpload: props.cancelUpload,
     toggleFileCard: props.toggleFileCard,
     removeFile: props.removeFile,
-    handleRequestThumbnail: props.handleRequestThumbnail
+    handleRequestThumbnail: props.handleRequestThumbnail,
+    handleCancelThumbnail: props.handleCancelThumbnail
   }
 
-  function renderItem (fileID) {
+  const rows = chunks(Object.keys(props.files), props.itemsPerRow)
+
+  function renderRow (row) {
     return (
-      <FileItem
-        key={fileID}
-        {...fileProps}
-        file={props.files[fileID]}
-      />
+      // The `role="presentation` attribute ensures that the list items are properly associated with the `VirtualList` element
+      // We use the first file ID as the key—this should not change across scroll rerenders
+      <div role="presentation" key={row[0]}>
+        {row.map((fileID) => (
+          <FileItem
+            key={fileID}
+            {...fileProps}
+            role="listitem"
+            file={props.files[fileID]}
+          />
+        ))}
+      </div>
     )
   }
 
   return (
-    <ul class={dashboardFilesClass}>
-      {Object.keys(props.files).map(renderItem)}
-    </ul>
+    <VirtualList
+      class={dashboardFilesClass}
+      role="list"
+      data={rows}
+      renderRow={renderRow}
+      rowHeight={rowHeight}
+    />
   )
 }

+ 159 - 0
packages/@uppy/dashboard/src/components/VirtualList.js

@@ -0,0 +1,159 @@
+/**
+ * Adapted from preact-virtual-list: https://github.com/developit/preact-virtual-list
+ *
+ * © 2016 Jason Miller
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ * Adaptations:
+ * - Added role=presentation to helper elements
+ * - Tweaked styles for Uppy's Dashboard use case
+ */
+
+const { h, Component } = require('preact')
+
+const STYLE_INNER = {
+  position: 'relative',
+  // Disabled for our use case: the wrapper elements around FileList already deal with overflow,
+  // and this additional property would hide things that we want to show.
+  //
+  // overflow: 'hidden',
+  width: '100%',
+  minHeight: '100%'
+}
+
+const STYLE_CONTENT = {
+  position: 'absolute',
+  top: 0,
+  left: 0,
+  // Because the `top` value gets set to some offset, this `height` being 100% would make the scrollbar
+  // stretch far beyond the content. For our use case, the content div actually can get its height from
+  // the elements inside it, so we don't need to specify a `height` property at all.
+  //
+  // height: '100%',
+  width: '100%',
+  overflow: 'visible'
+}
+
+class VirtualList extends Component {
+  constructor (props) {
+    super(props)
+
+    // The currently focused node, used to retain focus when the visible rows change.
+    // To avoid update loops, this should not cause state updates, so it's kept as a plain property.
+    this.focusElement = null
+
+    this.state = {
+      offset: 0,
+      height: 0
+    }
+  }
+
+  resize () {
+    if (this.state.height !== this.base.offsetHeight) {
+      this.setState({
+        height: this.base.offsetHeight
+      })
+    }
+  }
+
+  handleResize = () => {
+    this.resize()
+  }
+
+  handleScroll = () => {
+    this.setState({
+      offset: this.base.scrollTop
+    })
+    if (this.props.sync) {
+      this.forceUpdate()
+    }
+  }
+
+  componentWillUpdate () {
+    if (this.base.contains(document.activeElement)) {
+      this.focusElement = document.activeElement
+    }
+  }
+
+  componentDidUpdate () {
+    // Maintain focus when rows are added and removed.
+    if (this.focusElement && this.focusElement.parentNode &&
+        document.activeElement !== this.focusElement) {
+      this.focusElement.focus()
+    }
+    this.focusElement = null
+    this.resize()
+  }
+
+  componentDidMount () {
+    this.resize()
+    window.addEventListener('resize', this.handleResize)
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize)
+  }
+
+  render ({
+    data,
+    rowHeight,
+    renderRow,
+    overscanCount = 10,
+    sync,
+    ...props
+  }) {
+    const { offset, height } = this.state
+    // first visible row index
+    let start = Math.floor(offset / rowHeight)
+
+    // actual number of visible rows (without overscan)
+    let visibleRowCount = Math.floor(height / rowHeight)
+
+    // Overscan: render blocks of rows modulo an overscan row count
+    // This dramatically reduces DOM writes during scrolling
+    if (overscanCount) {
+      start = Math.max(0, start - (start % overscanCount))
+      visibleRowCount += overscanCount
+    }
+
+    // last visible + overscan row index + padding to allow keyboard focus to travel past the visible area
+    const end = start + visibleRowCount + 4
+
+    // data slice currently in viewport plus overscan items
+    const selection = data.slice(start, end)
+
+    const styleInner = { ...STYLE_INNER, height: data.length * rowHeight }
+    const styleContent = { ...STYLE_CONTENT, top: start * rowHeight }
+
+    // The `role="presentation"` attributes ensure that these wrapper elements are not treated as list
+    // items by accessibility and outline tools.
+    return (
+      <div onScroll={this.handleScroll} {...props}>
+        <div role="presentation" style={styleInner}>
+          <div role="presentation" style={styleContent}>
+            {selection.map(renderRow)}
+          </div>
+        </div>
+      </div>
+    )
+  }
+}
+
+module.exports = VirtualList

+ 20 - 1
packages/@uppy/dashboard/src/index.js

@@ -570,6 +570,21 @@ module.exports = class Dashboard extends Plugin {
       })
   }
 
+  handleRequestThumbnail = (file) => {
+    if (!this.opts.waitForThumbnailsBeforeUpload) {
+      this.uppy.emit('thumbnail:request', file)
+    }
+  }
+
+  /**
+   * We cancel thumbnail requests when a file item component unmounts to avoid clogging up the queue when the user scrolls past many elements.
+   */
+  handleCancelThumbnail = (file) => {
+    if (!this.opts.waitForThumbnailsBeforeUpload) {
+      this.uppy.emit('thumbnail:cancel', file)
+    }
+  }
+
   handleKeyDownInInline = (event) => {
     // Trap focus on tab key press.
     if (event.keyCode === TAB_KEY) trapFocus.forInline(event, this.getPluginState().activeOverlayType, this.el)
@@ -845,6 +860,8 @@ module.exports = class Dashboard extends Plugin {
       allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
       maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles,
       showSelectedFiles: this.opts.showSelectedFiles,
+      handleRequestThumbnail: this.handleRequestThumbnail,
+      handleCancelThumbnail: this.handleCancelThumbnail,
       // drag props
       isDraggingOver: pluginState.isDraggingOver,
       handleDragOver: this.handleDragOver,
@@ -924,7 +941,9 @@ module.exports = class Dashboard extends Plugin {
       this.uppy.use(ThumbnailGenerator, {
         id: `${this.id}:ThumbnailGenerator`,
         thumbnailWidth: this.opts.thumbnailWidth,
-        waitForThumbnailsBeforeUpload: this.opts.waitForThumbnailsBeforeUpload
+        waitForThumbnailsBeforeUpload: this.opts.waitForThumbnailsBeforeUpload,
+        // If we don't block on thumbnails, we can lazily generate them
+        lazy: !this.opts.waitForThumbnailsBeforeUpload
       })
     }
 

+ 41 - 9
packages/@uppy/thumbnail-generator/src/index.js

@@ -31,11 +31,16 @@ module.exports = class ThumbnailGenerator extends Plugin {
     const defaultOptions = {
       thumbnailWidth: null,
       thumbnailHeight: null,
-      waitForThumbnailsBeforeUpload: false
+      waitForThumbnailsBeforeUpload: false,
+      lazy: false
     }
 
     this.opts = { ...defaultOptions, ...opts }
 
+    if (this.opts.lazy && this.opts.waitForThumbnailsBeforeUpload) {
+      throw new Error('ThumbnailGenerator: The `lazy` and `waitForThumbnailsBeforeUpload` options are mutually exclusive. Please ensure at most one of them is set to `true`.')
+    }
+
     this.i18nInit()
   }
 
@@ -262,7 +267,11 @@ module.exports = class ThumbnailGenerator extends Plugin {
   processQueue () {
     this.queueProcessing = true
     if (this.queue.length > 0) {
-      const current = this.queue.shift()
+      const current = this.uppy.getFile(this.queue.shift())
+      if (!current) {
+        this.uppy.log('[ThumbnailGenerator] file was removed before a thumbnail could be generated, but not removed from the queue. This is probably a bug', 'error')
+        return
+      }
       return this.requestThumbnail(current)
         .catch(err => {}) // eslint-disable-line handle-callback-err
         .then(() => this.processQueue())
@@ -292,12 +301,25 @@ module.exports = class ThumbnailGenerator extends Plugin {
 
   onFileAdded = (file) => {
     if (!file.preview && isPreviewSupported(file.type) && !file.isRemote) {
-      this.addToQueue(file)
+      this.addToQueue(file.id)
+    }
+  }
+
+  /**
+   * Cancel a lazy request for a thumbnail if the thumbnail has not yet been generated.
+   */
+  onCancelRequest = (file) => {
+    const index = this.queue.indexOf(file.id)
+    if (index !== -1) {
+      this.queue.splice(index, 1)
     }
   }
 
+  /**
+   * Clean up the thumbnail for a file. Cancel lazy requests and free the thumbnail URL.
+   */
   onFileRemoved = (file) => {
-    const index = this.queue.indexOf(file)
+    const index = this.queue.indexOf(file.id)
     if (index !== -1) {
       this.queue.splice(index, 1)
     }
@@ -316,7 +338,7 @@ module.exports = class ThumbnailGenerator extends Plugin {
       if (!file.isRestored) return
       // Only add blob URLs; they are likely invalid after being restored.
       if (!file.preview || isObjectURL(file.preview)) {
-        this.addToQueue(file)
+        this.addToQueue(file.id)
       }
     })
   }
@@ -352,8 +374,13 @@ module.exports = class ThumbnailGenerator extends Plugin {
 
   install () {
     this.uppy.on('file-removed', this.onFileRemoved)
-    this.uppy.on('file-added', this.onFileAdded)
-    this.uppy.on('restored', this.onRestored)
+    if (this.opts.lazy) {
+      this.uppy.on('thumbnail:request', this.onFileAdded)
+      this.uppy.on('thumbnail:cancel', this.onCancelRequest)
+    } else {
+      this.uppy.on('file-added', this.onFileAdded)
+      this.uppy.on('restored', this.onRestored)
+    }
 
     if (this.opts.waitForThumbnailsBeforeUpload) {
       this.uppy.addPreProcessor(this.waitUntilAllProcessed)
@@ -362,8 +389,13 @@ module.exports = class ThumbnailGenerator extends Plugin {
 
   uninstall () {
     this.uppy.off('file-removed', this.onFileRemoved)
-    this.uppy.off('file-added', this.onFileAdded)
-    this.uppy.off('restored', this.onRestored)
+    if (this.opts.lazy) {
+      this.uppy.off('thumbnail:request', this.onFileAdded)
+      this.uppy.off('thumbnail:cancel', this.onCancelRequest)
+    } else {
+      this.uppy.off('file-added', this.onFileAdded)
+      this.uppy.off('restored', this.onRestored)
+    }
 
     if (this.opts.waitForThumbnailsBeforeUpload) {
       this.uppy.removePreProcessor(this.waitUntilAllProcessed)

+ 30 - 20
packages/@uppy/thumbnail-generator/src/index.test.js

@@ -13,7 +13,11 @@ function MockCore () {
   }
   core.mockFile = (id, f) => { files[id] = f }
   core.getFile = (id) => files[id]
-  core.log = () => null
+  core.log = (message, level = 'log') => {
+    if (level === 'warn' || level === 'error') {
+      console[level](message)
+    }
+  }
   core.getState = () => core.state
   core.setState = () => null
   return core
@@ -78,31 +82,34 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
       const plugin = new ThumbnailGeneratorPlugin(core)
       plugin.processQueue = jest.fn()
 
-      const file = { foo: 'bar' }
+      const file = { id: 'bar', type: 'image/jpeg' }
       plugin.queueProcessing = false
-      plugin.addToQueue(file)
-      expect(plugin.queue).toEqual([{ foo: 'bar' }])
+      plugin.addToQueue(file.id)
+      expect(plugin.queue).toEqual(['bar'])
       expect(plugin.processQueue).toHaveBeenCalledTimes(1)
 
-      const file2 = { foo: 'bar2' }
+      const file2 = { id: 'bar2', type: 'image/jpeg' }
       plugin.queueProcessing = true
-      plugin.addToQueue(file2)
-      expect(plugin.queue).toEqual([{ foo: 'bar' }, { foo: 'bar2' }])
+      plugin.addToQueue(file2.id)
+      expect(plugin.queue).toEqual(['bar', 'bar2'])
       expect(plugin.processQueue).toHaveBeenCalledTimes(1)
     })
 
     it('should process items in the queue one by one', () => {
       const core = new MockCore()
       const plugin = new ThumbnailGeneratorPlugin(core)
-
       plugin.requestThumbnail = jest.fn(() => delay(100))
+      plugin.install()
 
-      const file1 = { foo: 'bar' }
-      const file2 = { foo: 'bar2' }
-      const file3 = { foo: 'bar3' }
-      plugin.addToQueue(file1)
-      plugin.addToQueue(file2)
-      plugin.addToQueue(file3)
+      const file1 = { id: 'bar', type: 'image/jpeg' }
+      const file2 = { id: 'bar2', type: 'image/jpeg' }
+      const file3 = { id: 'bar3', type: 'image/jpeg' }
+      core.mockFile(file1.id, file1)
+      core.emit('file-added', file1)
+      core.mockFile(file2.id, file2)
+      core.emit('file-added', file2)
+      core.mockFile(file3.id, file3)
+      core.emit('file-added', file3)
 
       expect(plugin.requestThumbnail).toHaveBeenCalledTimes(1)
       expect(plugin.requestThumbnail).toHaveBeenCalledWith(file1)
@@ -143,7 +150,9 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
 
         const file1 = { id: 1, name: 'bar.jpg', type: 'image/jpeg' }
         const file2 = { id: 2, name: 'bar2.jpg', type: 'image/jpeg' }
+        core.mockFile(file1.id, file1)
         core.emit('file-added', file1)
+        core.mockFile(file2.id, file2)
         core.emit('file-added', file2)
         expect(plugin.queue).toHaveLength(1)
         // should drop it from the queue
@@ -168,10 +177,11 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
     const plugin = new ThumbnailGeneratorPlugin(core)
     plugin.createThumbnail = jest.fn((file) => delay(100).then(() => `blob:${file.id}.png`))
     plugin.setPreviewURL = jest.fn()
+    plugin.install()
 
     function add (file) {
       core.mockFile(file.id, file)
-      plugin.addToQueue(file)
+      core.emit('file-added', file)
     }
 
     it('should emit thumbnail:generated when a thumbnail was generated', () => new Promise((resolve, reject) => {
@@ -474,9 +484,9 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
   describe('onRestored', () => {
     it('should enqueue restored files', () => {
       const files = {
-        a: { preview: 'blob:abc', isRestored: true },
-        b: { preview: 'blob:def' },
-        c: { preview: 'blob:xyz', isRestored: true }
+        a: { id: 'a', type: 'image/jpeg', preview: 'blob:abc', isRestored: true },
+        b: { id: 'b', type: 'image/jpeg', preview: 'blob:def' },
+        c: { id: 'c', type: 'image/jpeg', preview: 'blob:xyz', isRestored: true }
       }
       const core = Object.assign(new MockCore(), {
         getState () {
@@ -494,8 +504,8 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
       core.emit('restored')
 
       expect(plugin.addToQueue).toHaveBeenCalledTimes(2)
-      expect(plugin.addToQueue).toHaveBeenCalledWith(files.a)
-      expect(plugin.addToQueue).toHaveBeenCalledWith(files.c)
+      expect(plugin.addToQueue).toHaveBeenCalledWith(files.a.id)
+      expect(plugin.addToQueue).toHaveBeenCalledWith(files.c.id)
     })
 
     it('should not regenerate thumbnail for remote files', () => {