diff --git a/examples/bundled/index.js b/examples/bundled/index.js index 6df562565..6fac992ad 100644 --- a/examples/bundled/index.js +++ b/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' }) diff --git a/examples/dev/Dashboard.js b/examples/dev/Dashboard.js index 8aa98ebc7..071e19a9d 100644 --- a/examples/dev/Dashboard.js +++ b/examples/dev/Dashboard.js @@ -58,6 +58,7 @@ module.exports = () => { username: 'John', license: 'Creative Commons', }, + restrictions: { requiredMetaFields: ['caption'] }, }) .use(Dashboard, { trigger: '#pick-files', diff --git a/packages/@uppy/core/src/index.js b/packages/@uppy/core/src/index.js index 6375ee2b7..60e3c3c9f 100644 --- a/packages/@uppy/core/src/index.js +++ b/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) }) diff --git a/packages/@uppy/dashboard/src/components/FileCard/index.js b/packages/@uppy/dashboard/src/components/FileCard/index.js index 7fd14931c..aa0a75412 100644 --- a/packages/@uppy/dashboard/src/components/FileCard/index.js +++ b/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 (
@@ -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) : ( this.updateMeta(ev.target.value, field.id)} data-uppy-super-focusable /> @@ -112,6 +122,7 @@ class FileCard extends Component { @@ -141,8 +153,8 @@ class FileCard extends Component {
@@ -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')} diff --git a/packages/@uppy/dashboard/src/index.js b/packages/@uppy/dashboard/src/index.js index 690ee070d..4c306413f 100644 --- a/packages/@uppy/dashboard/src/index.js +++ b/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, diff --git a/packages/@uppy/dashboard/types/index.d.ts b/packages/@uppy/dashboard/types/index.d.ts index c751ee5de..919eb7317 100644 --- a/packages/@uppy/dashboard/types/index.d.ts +++ b/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 diff --git a/website/src/docs/dashboard.md b/website/src/docs/dashboard.md index 67053d43d..ef38887d2 100644 --- a/website/src/docs/dashboard.md +++ b/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, }) }, }) diff --git a/website/src/docs/uppy.md b/website/src/docs/uppy.md index 71f7c24d0..8eacbf1eb 100644 --- a/website/src/docs/uppy.md +++ b/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. diff --git a/website/src/examples/dashboard/app.es6 b/website/src/examples/dashboard/app.es6 index b773ad4e8..39bfdba4e 100644 --- a/website/src/examples/dashboard/app.es6 +++ b/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 }) diff --git a/website/src/examples/dashboard/index.ejs b/website/src/examples/dashboard/index.ejs index f7ef11d79..03a604bdb 100644 --- a/website/src/examples/dashboard/index.ejs +++ b/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, {