Bladeren bron

Dashboard: add `required` option to `metaFields` (#2896)

* Dashboard: add `required` option to `metaFields`

Fixes: https://github.com/transloadit/uppy/issues/2892

* use `<form>` element to allow users to use all built-in form features
from the web browser.

* remove `saveOnEnter` hack

The fields are now inside a form, saving on Enter is the default
behavior – and it doesn't short-circuit form validation checks.

* restrictions.requiredMetaFields

* fix documentation of restrictions.requiredMetaFields

* check for required meta fields on upload

* Use detached `<form>` element to avoid nesting `<form>` elements

* fixup! Use detached `<form>` element to avoid nesting `<form>` elements

* Use `cuid` to generate `<form>` `id`
Antoine du Hamel 3 jaren geleden
bovenliggende
commit
56b84dfec7

+ 1 - 0
examples/bundled/index.js

@@ -32,6 +32,7 @@ const uppy = new Uppy({
     showProgressDetails: true,
     proudlyDisplayPoweredByUppy: true,
     note: '2 files, images and video only',
+    restrictions: { requiredMetaFields: ['caption'] },
   })
   .use(GoogleDrive, { target: Dashboard, companionUrl: 'http://localhost:3020' })
   .use(Instagram, { target: Dashboard, companionUrl: 'http://localhost:3020' })

+ 1 - 0
examples/dev/Dashboard.js

@@ -58,6 +58,7 @@ module.exports = () => {
       username: 'John',
       license: 'Creative Commons',
     },
+    restrictions: { requiredMetaFields: ['caption'] },
   })
     .use(Dashboard, {
       trigger: '#pick-files',

+ 49 - 1
packages/@uppy/core/src/index.js

@@ -21,6 +21,21 @@ class RestrictionError extends Error {
     this.isRestriction = true
   }
 }
+if (typeof window.AggregateError === 'undefined') {
+  // eslint-disable-next-line no-global-assign
+  AggregateError = class AggregateError extends Error {
+    constructor (message, errors) {
+      super(message)
+      this.errors = errors
+    }
+  }
+}
+class AggregateRestrictionError extends AggregateError {
+  constructor (...args) {
+    super(...args)
+    this.isRestriction = true
+  }
+}
 
 /**
  * Uppy Core module.
@@ -51,6 +66,8 @@ class Uppy {
           0: 'You have to select at least %{smart_count} file',
           1: 'You have to select at least %{smart_count} files',
         },
+        missingRequiredMetaField: 'Missing required meta fields',
+        missingRequiredMetaFieldOnFile: 'Missing required meta fields in %{fileName}',
         // The default `exceedsSize2` string only combines the `exceedsSize` string (%{backwardsCompat}) with the size.
         // Locales can override `exceedsSize2` to specify a different word order. This is for backwards compat with
         // Uppy 1.9.x and below which did a naive concatenation of `exceedsSize2 + size` instead of using a locale-specific
@@ -108,6 +125,7 @@ class Uppy {
         maxNumberOfFiles: null,
         minNumberOfFiles: null,
         allowedFileTypes: null,
+        requiredMetaFields: [],
       },
       meta: {},
       onBeforeFileAdded: (currentFile) => currentFile,
@@ -542,6 +560,33 @@ class Uppy {
     }
   }
 
+  /**
+   * Check if requiredMetaField restriction is met before uploading.
+   *
+   * @private
+   */
+  checkRequiredMetaFields (files) {
+    const { requiredMetaFields } = this.opts.restrictions
+    const { hasOwnProperty } = Object.prototype.hasOwnProperty
+
+    const errors = []
+    const fileIDs = Object.keys(files)
+    for (let i = 0; i < fileIDs.length; i++) {
+      const file = this.getFile(fileIDs[i])
+      for (let i = 0; i < requiredMetaFields.length; i++) {
+        if (!hasOwnProperty.call(file.meta, requiredMetaFields[i])) {
+          const err = new RestrictionError(`${this.i18n('missingRequiredMetaFieldOnFile', { fileName: file.name })}`)
+          errors.push(err)
+          this.showOrLogErrorAndThrow(err, { file, throwErr: false })
+        }
+      }
+    }
+
+    if (errors.length) {
+      throw new AggregateRestrictionError(`${this.i18n('missingRequiredMetaField')}`, errors)
+    }
+  }
+
   /**
    * Logs an error, sets Informer message, then throws the error.
    * Emits a 'restriction-failed' event if it’s a restriction error
@@ -1688,7 +1733,10 @@ class Uppy {
     }
 
     return Promise.resolve()
-      .then(() => this.checkMinNumberOfFiles(files))
+      .then(() => {
+        this.checkMinNumberOfFiles(files)
+        this.checkRequiredMetaFields(files)
+      })
       .catch((err) => {
         this.showOrLogErrorAndThrow(err)
       })

+ 27 - 14
packages/@uppy/dashboard/src/components/FileCard/index.js

@@ -1,5 +1,6 @@
 const { h, Component } = require('preact')
 const classNames = require('classnames')
+const cuid = require('cuid')
 const getFileTypeIcon = require('../../utils/getFileTypeIcon')
 const ignoreEvent = require('../../utils/ignoreEvent.js')
 const FilePreview = require('../FilePreview')
@@ -19,15 +20,8 @@ class FileCard extends Component {
     this.state = {
       formState: storedMetaData,
     }
-  }
 
-  saveOnEnter = (ev) => {
-    if (ev.keyCode === 13) {
-      ev.stopPropagation()
-      ev.preventDefault()
-      const file = this.props.files[this.props.fileCardFor]
-      this.props.saveFileCard(this.state.formState, file.id)
-    }
+    this.form.id = cuid()
   }
 
   updateMeta = (newVal, name) => {
@@ -39,7 +33,10 @@ class FileCard extends Component {
     })
   }
 
-  handleSave = () => {
+  form = document.createElement('form');
+
+  handleSave = (e) => {
+    e.preventDefault()
     const fileID = this.props.fileCardFor
     this.props.saveFileCard(this.state.formState, fileID)
   }
@@ -48,6 +45,17 @@ class FileCard extends Component {
     this.props.toggleFileCard(false)
   }
 
+  // TODO(aduh95): move this to `UNSAFE_componentWillMount` when updating to Preact X+.
+  componentWillMount () {
+    this.form.addEventListener('submit', this.handleSave)
+    document.body.appendChild(this.form)
+  }
+
+  componentWillUnmount () {
+    this.form.removeEventListener('submit', this.handleSave)
+    document.body.removeChild(this.form)
+  }
+
   renderMetaFields = () => {
     const metaFields = this.getMetaFields() || []
     const fieldCSSClasses = {
@@ -56,6 +64,7 @@ class FileCard extends Component {
 
     return metaFields.map((field) => {
       const id = `uppy-Dashboard-FileCard-input-${field.id}`
+      const required = this.props.requiredMetaFields.includes(field.id)
       return (
         <fieldset key={field.id} className="uppy-Dashboard-FileCard-fieldset">
           <label className="uppy-Dashboard-FileCard-label" htmlFor={id}>{field.name}</label>
@@ -64,17 +73,18 @@ class FileCard extends Component {
               value: this.state.formState[field.id],
               onChange: (newVal) => this.updateMeta(newVal, field.id),
               fieldCSSClasses,
+              required,
+              form: this.form.id,
             }, h)
             : (
               <input
                 className={fieldCSSClasses.text}
                 id={id}
+                form={this.form.id}
                 type={field.type || 'text'}
+                required={required}
                 value={this.state.formState[field.id]}
                 placeholder={field.placeholder}
-                onKeyUp={this.saveOnEnter}
-                onKeyDown={this.saveOnEnter}
-                onKeyPress={this.saveOnEnter}
                 onInput={ev => this.updateMeta(ev.target.value, field.id)}
                 data-uppy-super-focusable
               />
@@ -112,6 +122,7 @@ class FileCard extends Component {
           <button
             className="uppy-DashboardContent-back"
             type="button"
+            form={this.form.id}
             title={this.props.i18n('finishEditingFile')}
             onClick={this.handleCancel}
           >
@@ -128,6 +139,7 @@ class FileCard extends Component {
                 type="button"
                 className="uppy-u-reset uppy-c-btn uppy-Dashboard-FileCard-edit"
                 onClick={() => this.props.openFileEditor(file)}
+                form={this.form.id}
               >
                 {this.props.i18n('editFile')}
               </button>
@@ -141,8 +153,8 @@ class FileCard extends Component {
           <div className="uppy-Dashboard-FileCard-actions">
             <button
               className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Dashboard-FileCard-actionsBtn"
-              type="button"
-              onClick={this.handleSave}
+              type="submit"
+              form={this.form.id}
             >
               {this.props.i18n('saveChanges')}
             </button>
@@ -150,6 +162,7 @@ class FileCard extends Component {
               className="uppy-u-reset uppy-c-btn uppy-c-btn-link uppy-Dashboard-FileCard-actionsBtn"
               type="button"
               onClick={this.handleCancel}
+              form={this.form.id}
             >
               {this.props.i18n('cancel')}
             </button>

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

@@ -1025,6 +1025,7 @@ module.exports = class Dashboard extends Plugin {
       parentElement: this.el,
       allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
       maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles,
+      requiredMetaFields: this.uppy.opts.restrictions.requiredMetaFields,
       showSelectedFiles: this.opts.showSelectedFiles,
       handleCancelRestore: this.handleCancelRestore,
       handleRequestThumbnail: this.handleRequestThumbnail,

+ 2 - 0
packages/@uppy/dashboard/types/index.d.ts

@@ -5,6 +5,8 @@ import DashboardLocale = require('./generatedLocale')
 type FieldRenderOptions = {
   value: string,
   onChange: (newVal: string) => void
+  fieldCSSClasses: { text: string }
+  required?: boolean
 }
 
 type PreactRender = (node: any, params: object | null, ...children: any[]) => any

+ 7 - 6
website/src/docs/dashboard.md

@@ -234,8 +234,8 @@ An array of UI field objects, or a function that takes a [File Object](https://u
 - `name`, the label shown in the interface.
 - `placeholder`, the text shown when no value is set in the field. (Not needed when a custom render function is provided)
 
-Optionally, you can specify `render: ({value, onChange}, h) => void`, a function for rendering a custom form element.
-It gets passed `({value, onChange}, h)` where `value` is the current value of the meta field, `onChange: (newVal) => void` is a function saving the new value and `h` is the `createElement` function from [preact](https://preactjs.com/guide/v10/api-reference#h--createelement).
+Optionally, you can specify `render: ({value, onChange, required}, h) => void`, a function for rendering a custom form element.
+It gets passed `({value, onChange, required}, h)` where `value` is the current value of the meta field, `required` is a boolean that's true if the field `id` is in the `restrictedMetaFields` restriction, and `onChange: (newVal) => void` is a function saving the new value and `h` is the `createElement` function from [preact](https://preactjs.com/guide/v10/api-reference#h--createelement).
 `h` can be useful when using uppy from plain JavaScript, where you cannot write JSX.
 
 ```js
@@ -245,10 +245,10 @@ It gets passed `({value, onChange}, h)` where `value` is the current value of th
     { id: 'name', name: 'Name', placeholder: 'file name' },
     { id: 'license', name: 'License', placeholder: 'specify license' },
     { id: 'caption', name: 'Caption', placeholder: 'describe what the image is about' },
-    { id: 'public', name: 'Public', render: function({value, onChange}, h) {
-      return h('input', { type: 'checkbox', onChange: (ev) => onChange(ev.target.checked ? 'on' : 'off'), defaultChecked: value === 'on' })
+    { id: 'public', name: 'Public', render: function({value, onChange, required}, h) {
+      return h('input', { type: 'checkbox', required, onChange: (ev) => onChange(ev.target.checked ? 'on' : 'off'), defaultChecked: value === 'on' })
     } }
-  ]
+  ],
 })
 ```
 
@@ -265,11 +265,12 @@ If you’d like the meta fields to be dynamically assigned depending on, for ins
       fields.push({
         id: 'public',
         name: 'Public',
-        render: ({ value, onChange }, h) => {
+        render: ({ value, onChange, required }, h) => {
           return h('input', {
             type: 'checkbox',
             onChange: (ev) => onChange(ev.target.checked ? 'on' : 'off'),
             defaultChecked: value === 'on',
+            required,
           })
         },
       })

+ 3 - 1
website/src/docs/uppy.md

@@ -79,7 +79,8 @@ const uppy = new Uppy({
     maxTotalFileSize: null,
     maxNumberOfFiles: null,
     minNumberOfFiles: null,
-    allowedFileTypes: null
+    allowedFileTypes: null,
+    requiredMetaFields: [],
   },
   meta: {},
   onBeforeFileAdded: (currentFile, files) => currentFile,
@@ -170,6 +171,7 @@ Optionally, provide rules and conditions to limit the type and/or number of file
 - `maxNumberOfFiles` *null | number* — total number of files that can be selected
 - `minNumberOfFiles` *null | number* — minimum number of files that must be selected before the upload
 - `allowedFileTypes` *null | array* of wildcards `image/*`, exact mime types `image/jpeg`, or file extensions `.jpg`: `['image/*', '.jpg', '.jpeg', '.png', '.gif']`
+- `requiredMetaFields` *array* of strings
 
 `maxNumberOfFiles` also affects the number of files a user is able to select via the system file dialog in UI plugins like `DragDrop`, `FileInput` and `Dashboard`: when set to `1`, they will only be able to select a single file. When `null` or another number is provided, they will be able to select multiple files.
 

+ 1 - 0
website/src/examples/dashboard/app.es6

@@ -37,6 +37,7 @@ function uppyInit () {
 
   const uppy = new Uppy({
     logger: Uppy.debugLogger,
+    restrictions: { requiredMetaFields: ['caption'] }
   })
 
   uppy.use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/', resume: true })

+ 2 - 1
website/src/examples/dashboard/index.ejs

@@ -45,7 +45,8 @@ const uppy = new Uppy({
     maxFileSize: 1000000,
     maxNumberOfFiles: 3,
     minNumberOfFiles: 2,
-    allowedFileTypes: ['image/*', 'video/*']
+    allowedFileTypes: ['image/*', 'video/*'],
+    requiredMetaFields: ['caption'],
   }
 })
 .use(Dashboard, {