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`
This commit is contained in:
Antoine du Hamel 2021-07-29 14:15:36 +02:00 committed by GitHub
parent 8ac63d43e0
commit 56b84dfec7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 94 additions and 23 deletions

View file

@ -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' })

View file

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

View file

@ -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 its 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)
})

View file

@ -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>

View file

@ -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,

View file

@ -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

View file

@ -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 youd 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,
})
},
})

View file

@ -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.

View file

@ -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 })

View file

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