diff --git a/README.md b/README.md index d89f7f8..ad3489c 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,19 @@ In your project's root folder run: To avoid using `Npm.depends` this package does not include any NPM module, so you will have to install them yourself by running the following command: ``` -meteor npm install async-busboy@^0.6.2 brackets2dots@^1.1.0 lodash@^4.0.0 object-path@^0.11.4 randomstring@^1.1.5 react-dropzone@^3.12.2 recursive-iterator@^3.3.0 knox@^0.9.2 gm@^1.23.1 +meteor npm install gm@^1.23.1 lodash@^4.0.0 randomstring@^1.1.5 react-dropzone@^3.12.2 recursive-iterator@^3.3.0 apollo-upload-client@^11.0.0 ``` Alternatively, here you have a list of the packages and versions required, so you can add them to your project's `package.json`: ```json { - "async-busboy": "^0.6.2", - "brackets2dots": "^1.1.0", "gm": "^1.23.1", - "knox": "^0.9.2", "lodash": "^4.0.0", - "object-path": "^0.11.4", "randomstring": "^1.1.5", "react-dropzone": "^3.12.2", - "recursive-iterator": "^3.3.0" + "recursive-iterator": "^3.3.0", + "apollo-upload-client": "^11.0.0" } ``` diff --git a/lib/client/interface.js b/lib/client/interface.js index 40b90f9..5c340f5 100644 --- a/lib/client/interface.js +++ b/lib/client/interface.js @@ -68,58 +68,59 @@ function serializeFormData(obj, formDataObj = {}, namespace) { return formDataObj; } -export const fileUploadMiddleware = { - applyMiddleware({ request, options }, next) { - if (isUpload(request)) { - const body = new FormData(); - const data = []; - const printed = printRequest(request); - data.push({ - operationName: printed.operationName || undefined, - debugName: printed.debugName || undefined, - query: printed.query, - variables: request.variables || {} - }); - const serialised = serializeFormData({ data }); - Object.entries(serialised).forEach(([name, value]) => { - if (typeof value !== typeof undefined) { - body.set(name, value); - } - }); - options.headers = options.headers || new Headers(); - options.headers.Accept = '*/*'; - options.headers['Content-Type'] = undefined; - options.body = body; - } - next(); - }, - applyBatchMiddleware({ requests, options }, next) { - if (isUpload(requests)) { - const body = new FormData(); - const data = []; - requests.forEach((request, i) => { - const printed = printRequest(request); - data.push({ - operationName: printed.operationName || undefined, - debugName: printed.debugName || undefined, - query: printed.query, - variables: request.variables || {} - }); - }); - const serialised = serializeFormData({ data }); - Object.entries(serialised).forEach(([name, value]) => { - if (typeof value !== typeof undefined) { - body.set(name, value); - } - }); - options.headers = options.headers || new Headers(); - options.headers.Accept = '*/*'; - options.headers['Content-Type'] = undefined; - options.body = body; - } - next(); - } -}; +// DEPRECATED with apollo 2 +//export const fileUploadMiddleware = { +// applyMiddleware({ request, options }, next) { +// if (isUpload(request)) { +// const body = new FormData(); +// const data = []; +// const printed = printRequest(request); +// data.push({ +// operationName: printed.operationName || undefined, +// debugName: printed.debugName || undefined, +// query: printed.query, +// variables: request.variables || {} +// }); +// const serialised = serializeFormData({ data }); +// Object.entries(serialised).forEach(([name, value]) => { +// if (typeof value !== typeof undefined) { +// body.set(name, value); +// } +// }); +// options.headers = options.headers || new Headers(); +// options.headers.Accept = '*/*'; +// options.headers['Content-Type'] = undefined; +// options.body = body; +// } +// next(); +// }, +// applyBatchMiddleware({ requests, options }, next) { +// if (isUpload(requests)) { +// const body = new FormData(); +// const data = []; +// requests.forEach((request, i) => { +// const printed = printRequest(request); +// data.push({ +// operationName: printed.operationName || undefined, +// debugName: printed.debugName || undefined, +// query: printed.query, +// variables: request.variables || {} +// }); +// }); +// const serialised = serializeFormData({ data }); +// Object.entries(serialised).forEach(([name, value]) => { +// if (typeof value !== typeof undefined) { +// body.set(name, value); +// } +// }); +// options.headers = options.headers || new Headers(); +// options.headers.Accept = '*/*'; +// options.headers['Content-Type'] = undefined; +// options.body = body; +// } +// next(); +// } +//}; export function createUploadNetworkInterface(opts) { const batchedInterface = createBatchingNetworkInterface(opts); diff --git a/lib/client/main.js b/lib/client/main.js index 215a981..2d45161 100644 --- a/lib/client/main.js +++ b/lib/client/main.js @@ -1,12 +1,10 @@ -import { withRenderContext } from 'meteor/vulcan:lib'; -import { fileUploadMiddleware } from './interface'; +import { registerTerminatingLink } from 'meteor/vulcan:lib'; +import { createUploadLink } from 'apollo-upload-client'; + +registerTerminatingLink(createUploadLink()) + export * from '../modules'; export { default as generateFieldSchema } from '../modules/generateFieldSchemaBase'; export { default as createFSCollection } from './createFSCollectionStub'; -Meteor.startup(() => { - withRenderContext(renderContext => { - renderContext.apolloClient.networkInterface.use([fileUploadMiddleware]); - }); -}); diff --git a/lib/modules/components/Upload.jsx b/lib/modules/components/Upload.jsx deleted file mode 100644 index bbe7c8a..0000000 --- a/lib/modules/components/Upload.jsx +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Stolen from vulcan:forms. Modified to work with GraphQL `File` scalars. - */ -import getContext from 'recompose/getContext'; -import upperFirst from 'lodash/upperFirst'; -import map from 'lodash/map'; -import reject from 'lodash/reject'; -import isEmpty from 'lodash/isEmpty'; -import reduce from 'lodash/reduce'; -import get from 'lodash/get'; -import isString from 'lodash/get'; -import stubTrue from 'lodash/stubTrue'; -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import Dropzone from 'react-dropzone'; -import 'isomorphic-fetch'; // patch for browser which don't have fetch implemented - -/* -Remove the nth item from an array -*/ -const removeNthItem = (array, n) => [ - ..._.first(array, n), - ..._.rest(array, n + 1), -]; - -/* -File Upload component -*/ -class Upload extends PureComponent { - static propTypes = { - name: PropTypes.string, - value: PropTypes.any, - label: PropTypes.string, - fileCheck: PropTypes.func, - FileRender: PropTypes.func.isRequired, - previewFromValue: PropTypes.func, - previewFromFile: PropTypes.func, - }; - - static defaultProps = { - fileCheck: stubTrue, - previewFromValue: () => '', - previewFromFile: value => ({ - name: get(value, 'name', ''), - url: get(value, 'preview', ''), - }), - }; - - - constructor(props, context) { - super(props, context); - - this.state = { - uploading: false, - errorMessage: null, - }; - } - - /* - When an file is uploaded - */ - onDrop = files => { - // Reset error state - this.setState({ - errorMessage: null, - }); - - // Check that files are valid - const errors = reject( - map(files, file => this.props.fileCheck(file)), - check => check === true - ); - // TODO add max files - - if (isEmpty(errors)) { - this.props.updateCurrentValues({ - [this.props.name]: this.enableMultiple() - ? [...this.getValue(), ...files] - : files[0], - }); - } else { - // TODO better error handling - // Set error message - const { - errorFilesTooBig = 'your file is too big', - errorFilesNotAllowedType = 'your file type is invalid', - } = this.props; - - this.setState({ - errorMessage: upperFirst( - reduce( - errors, - (result, error) => { - switch (error) { - case 'invalid-file-type': - return result + errorFilesNotAllowedType; - case 'exceed-max-allowed-size': - return result + errorFilesTooBig; - default: - return null; - } - }, - '' - ) - ), // TODO translate - }); - } - }; - - /* - Check the field's type to decide if the component should handle - multiple file uploads or not - */ - enableMultiple = () => ( - get(this.props, 'datatype.definitions[0].type') || - get(this.props, 'datatype[0].type') - ) === Array; - - getValue = () => this.props.value || (this.enableMultiple() ? [] : ''); - - /* - Remove the file at `index` (or just remove file if no index is passed) - */ - clearFile = index => { - const value = this.enableMultiple() - ? get(this.props.value, index) - : this.props.value; - if (!value) { - return; - } - - const url = get(this.preview(value, index), 'url'); - if (url) { - window.URL.revokeObjectURL(url); - } - - this.props.updateCurrentValues({ - [this.props.name]: this.enableMultiple() - ? removeNthItem(this.props.value, index) - : null, - }); - }; - - preview = (value, index = null) => { - return this.props.previewFromValue(value, index, this.props) || - this.props.previewFromFile(value, index, this.props); - }; - - render() { - const { - FileRender, - selectOrDropFilesMessage = 'Drop a file here, or click to select an file to upload.', - uploadingMessage = 'Uploading…', - ...props - } = this.props; - const value = this.getValue(); - const { uploading } = this.state; - - return ( -
- {this.props.label ? ( -
- {upperFirst(this.props.label)} -
- ) : null} -
-
- {isEmpty(value) || this.enableMultiple() ? ( - -
- {selectOrDropFilesMessage} - {/* Translate */} -
-
- ) : null} - - {!isEmpty(value) ? ( -
- {uploading ? {uploadingMessage} : null} -
- {this.enableMultiple() ? ( - value.map((value, index) => ( - - )) - ) : ( - - )} -
-
- ) : null} - {this.state.errorMessage ? ( -
- {this.state.errorMessage} -
- ) : null} -
-
-
- ); - } -} - -export default getContext({ - updateCurrentValues: PropTypes.func, - getDocument: PropTypes.func, -})(Upload); diff --git a/lib/modules/components/UploadInput.jsx b/lib/modules/components/UploadInput.jsx new file mode 100644 index 0000000..bf20df2 --- /dev/null +++ b/lib/modules/components/UploadInput.jsx @@ -0,0 +1,376 @@ +/** + * Stolen from vulcan:forms. Modified to work with GraphQL `File` scalars. + */ +import getContext from "recompose/getContext"; +import upperFirst from "lodash/upperFirst"; +import map from "lodash/map"; +import reject from "lodash/reject"; +import isEmpty from "lodash/isEmpty"; +import reduce from "lodash/reduce"; +import get from "lodash/get"; +import isString from "lodash/get"; +import stubTrue from "lodash/stubTrue"; +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import Dropzone from "react-dropzone"; +import "isomorphic-fetch"; // patch for browser which don't have fetch implemented +import { withComponents, registerComponent } from "meteor/vulcan:core"; +import withProps from "recompose/withProps"; +import { injectIntl } from "react-intl"; +import { compose } from "recompose"; + +// Visual components +const UploadInputLayout = ({ children }) =>
{children}
; +const UploadInputLabel = ({ label }) => ( +
{upperFirst(label)}
+); +const UploadInputDropZoneContent = ({ selectOrDropFilesMessage }) => ( +
+ {selectOrDropFilesMessage} + {/* Translate */} +
+); +const UploadInputErrorMessage = ({ errrorMessage }) => ( +
+ {errorMessage} +
+); + +// Full input +const UploadInput = props => { + const { + FileRender, + selectOrDropFilesMessage = "Drop a file here, or click to select an file to upload.", + uploadingMessage = "Uploading…", + + label, + value, + enableMultiple, + uploading, + errorMessage, + onDrop, + preview, + clearFile, + dropZoneProps, + Components, + } = props; + + const { + UploadInputLabel, + UploadInputDropZoneContent, + UploadInputErrorMessage, + UploadInputLayout, + } = Components; + + return ( + + {label ? : null} +
+
+ {isEmpty(value) || enableMultiple ? ( + + + + ) : null} + + {!isEmpty(value) ? ( +
+ {uploading ? {uploadingMessage} : null} +
+ {enableMultiple ? ( + value.map((value, index) => ( + + )) + ) : ( + + )} +
+
+ ) : null} + {errorMessage ? ( + + ) : null} +
+
+
+ ); +}; +UploadInput.propTypes = { + FileRender: PropTypes.any, + + selectOrDropFieldsMessage: PropTypes.string, + uploadingMessage: PropTypes.string, + label: PropTypes.string, + value: PropTypes.any, + enableMultiple: PropTypes.bool, + + preview: PropTypes.func.isRequired, + onDrop: PropTypes.func.isRequired, + clearFile: PropTypes.func.isRequired, + + uploading: PropTypes.bool, + errorMessage: PropTypes.string, + + dropZoneProps: PropTypes.object, // additionnal props passed to react-dropzone +}; + +/* +Remove the nth item from an array +*/ +const removeNthItem = (array, n) => [ + ..._.first(array, n), + ..._.rest(array, n + 1), +]; + +// Container with logic +/* +File Upload component +*/ +class UploadInputContainer extends PureComponent { + static propTypes = { + name: PropTypes.string, + value: PropTypes.any, + label: PropTypes.string, + fileCheck: PropTypes.func, + FileRender: PropTypes.func.isRequired, + previewFromValue: PropTypes.func, + previewFromFile: PropTypes.func, + }; + + static defaultProps = { + fileCheck: stubTrue, + previewFromValue: () => "", + previewFromFile: value => ({ + name: get(value, "name", ""), + url: get(value, "preview", ""), + }), + }; + + constructor(props, context) { + super(props, context); + + this.state = { + uploading: false, + errorMessage: null, + }; + } + + /* + When an file is uploaded + */ + onDrop = files => { + // Reset error state + this.setState({ + errorMessage: null, + }); + + // Check that files are valid + const errors = reject( + map(files, file => this.props.fileCheck(file)), + check => check === true + ); + // TODO add max files + + if (isEmpty(errors)) { + this.props.updateCurrentValues({ + [this.props.name]: this.enableMultiple() + ? [...this.getValue(), ...files] + : files[0], + }); + } else { + // TODO better error handling + // Set error message + const { + errorFilesTooBig = "your file is too big", + errorFilesNotAllowedType = "your file type is invalid", + } = this.props; + + this.setState({ + errorMessage: upperFirst( + reduce( + errors, + (result, error) => { + switch (error) { + case "invalid-file-type": + return result + errorFilesNotAllowedType; + case "exceed-max-allowed-size": + return result + errorFilesTooBig; + default: + return null; + } + }, + "" + ) + ), // TODO translate + }); + } + }; + + /* + Check the field's type to decide if the component should handle + multiple file uploads or not + */ + enableMultiple = () => + (get(this.props, "datatype.definitions[0].type") || + get(this.props, "datatype[0].type")) === Array; + + getValue = () => this.props.value || (this.enableMultiple() ? [] : ""); + + /* + Remove the file at `index` (or just remove file if no index is passed) + */ + clearFile = index => { + const value = this.enableMultiple() + ? get(this.props.value, index) + : this.props.value; + if (!value) { + return; + } + + const url = get(this.preview(value, index), "url"); + if (url) { + window.URL.revokeObjectURL(url); + } + + this.props.updateCurrentValues({ + [this.props.name]: this.enableMultiple() + ? removeNthItem(this.props.value, index) + : null, + }); + }; + + preview = (value, index = null) => { + return ( + this.props.previewFromValue(value, index, this.props) || + this.props.previewFromFile(value, index, this.props) + ); + }; + + render() { + const { + FileRender, + selectOrDropFilesMessage, + uploadingMessage, + removeMessage, + dropZoneProps = {}, + label, + Components, + } = this.props; + const { uploading, errorMessage } = this.state; + const { UploadInputInner } = Components; + return ( + + ); + } +} + +// add intel and context +const WrappedUploadInputContainer = compose( + injectIntl, + withProps(({ intl }) => ({ + selectOrDropFilesMessage: intl.formatMessage({ + id: "fileUpload.selectOrDropFilesMessage", + defaultMessage: "Drop a file here, or click to select an file to upload.", + }), + + uploadingMessage: intl.formatMessage({ + id: "fileUpload.uploadingMessage", + defaultMessage: "Uploading...", + }), + errorFilesNotAllowedType: intl.formatMessage({ + id: "fileUpload.errorFilesNotAllowedType", + defaultMessage: "your file type is invalid", + }), + errorFilesTooBig: intl.formatMessage({ + id: "fileUpload.errorFilesTooBig", + defaultMessage: "your file is too big", + }), + removeMessage: intl.formatMessage({ + id: "fileUpload.remove", + defaultMessage: "remove", + }), + })), + getContext({ + updateCurrentValues: PropTypes.func, + getDocument: PropTypes.func, + }) +)(UploadInputContainer); + +// registeration +registerComponent({ name: "UploadInputLayout", component: UploadInputLayout }); +registerComponent({ name: "UploadInputLabel", component: UploadInputLabel }); +registerComponent({ + name: "UploadInputDropZoneContent", + component: UploadInputDropZoneContent, +}); +registerComponent({ + name: "UploadInputErrorMessage", + component: UploadInputErrorMessage, +}); +registerComponent({ + name: "UploadInputInner", + component: UploadInput, + hocs: [withComponents], +}); +export default registerComponent({ + name: "UploadInput", + component: WrappedUploadInputContainer, + hocs: [withComponents], +}); diff --git a/lib/modules/components/index.js b/lib/modules/components/index.js index 4e714c5..105d3fd 100644 --- a/lib/modules/components/index.js +++ b/lib/modules/components/index.js @@ -1,3 +1,3 @@ -export { default as Upload } from './Upload.jsx'; +export { default as UploadInput } from './UploadInput.jsx'; export { default as Image } from './Image.jsx'; export { default as BasicFile } from './BasicFile.jsx'; diff --git a/lib/modules/containers/Upload.js b/lib/modules/containers/Upload.js deleted file mode 100644 index db5f77b..0000000 --- a/lib/modules/containers/Upload.js +++ /dev/null @@ -1,32 +0,0 @@ -import compose from 'recompose/compose'; -import withProps from 'recompose/withProps'; -import { injectIntl } from 'react-intl'; - -import Upload from '../components/Upload'; - -export default compose( - injectIntl, - withProps(({ intl }) => ({ - selectOrDropFilesMessage: intl.formatMessage({ - id: 'fileUpload.selectOrDropFilesMessage', - defaultMessage: 'Drop a file here, or click to select an file to upload.', - }), - - uploadingMessage: intl.formatMessage({ - id: 'fileUpload.uploadingMessage', - defaultMessage: 'Uploading...', - }), - errorFilesNotAllowedType: intl.formatMessage({ - id: 'fileUpload.errorFilesNotAllowedType', - defaultMessage: 'your file type is invalid', - }), - errorFilesTooBig: intl.formatMessage({ - id: 'fileUpload.errorFilesTooBig', - defaultMessage: 'your file is too big', - }), - removeMessage: intl.formatMessage({ - id: 'remove', - defaultMessage: 'remove' - }), - })) -)(Upload); diff --git a/lib/modules/generateFieldSchemaBase.js b/lib/modules/generateFieldSchemaBase.js index 741b6dd..86229db 100644 --- a/lib/modules/generateFieldSchemaBase.js +++ b/lib/modules/generateFieldSchemaBase.js @@ -1,13 +1,13 @@ -import SimpleSchema from 'simpl-schema'; -import merge from 'lodash/merge'; -import pickBy from 'lodash/pickBy'; -import once from 'lodash/once'; -import get from 'lodash/get'; -import isString from 'lodash/isString'; -import isFinite from 'lodash/isFinite'; +import SimpleSchema from "simpl-schema"; +import merge from "lodash/merge"; +import pickBy from "lodash/pickBy"; +import once from "lodash/once"; +import get from "lodash/get"; +import isString from "lodash/isString"; +import isFinite from "lodash/isFinite"; -import { FILE } from './graphql/types'; -import defaultResolveId from './defaultResolveId'; +import { FILE } from "./graphql/types"; +import defaultResolveId from "./defaultResolveId"; /** * Generates schema for a file field. @@ -42,12 +42,12 @@ import defaultResolveId from './defaultResolveId'; * @return {Object} * @function generateFieldSchemaBase */ -export default (options = {}) => { +const generateFieldSchemaBase = (options = {}) => { const { fieldName, fieldSchema = {}, fieldType = fieldSchema.type || String, - resolverName = get(fieldSchema, 'resolveAs.fieldName'), + resolverName = get(fieldSchema, "resolveAs.fieldName"), multiple = false, resolveId = defaultResolveId, } = options; @@ -56,36 +56,44 @@ export default (options = {}) => { // accept generic object so SimpleSchema does not coerce the file object // to the specified `fieldType` { type: Object, blackbox: true }, - fieldType, + fieldType ); - return pickBy({ - [fieldName]: merge( - { - control: 'Upload', - form: { - previewFromValue: once(() => (value, index, props) => { - if (isString(resolveId(value))) { - const resolvedValuePath = [resolverName]; - if (isFinite(index)) { - resolvedValuePath.push(index); - } - return get(props.document, resolvedValuePath); - } - return undefined; - }), - }, - }, - fieldSchema, - { - type: multiple ? Array : FileOrType, - resolveAs: { - fieldName: resolverName, - type: multiple ? `[${FILE}]` : FILE, - addOriginalField: true, - }, - } - ), - [`${fieldName}.$`]: multiple ? { type: FileOrType } : null, - }); + const formInputFieldSchema = { + control: "Upload", + form: { + previewFromValue: once(() => (value, index, props) => { + if (isString(resolveId(value))) { + const resolvedValuePath = [resolverName]; + if (isFinite(index)) { + resolvedValuePath.push(index); + } + return get(props.document, resolvedValuePath); + } + return undefined; + }), + }, + }; + + const graphqlFieldSchema = { + type: multiple ? Array : FileOrType, + resolveAs: { + fieldName: resolverName, + type: multiple ? `[${FILE}]` : FILE, + addOriginalField: true, + }, + }; + + const enhancedFieldSchema = merge( + formInputFieldSchema, + fieldSchema, + graphqlFieldSchema + ); + const schema = { + [fieldName]: enhancedFieldSchema, + }; + if (multiple) schema[`${fieldName}.$`] = { type: FileOrType }; + return schema; }; + +export default generateFieldSchemaBase; diff --git a/lib/modules/graphql/types.js b/lib/modules/graphql/types.js index 81cfa47..bd5611b 100644 --- a/lib/modules/graphql/types.js +++ b/lib/modules/graphql/types.js @@ -1,7 +1,8 @@ import { addGraphQLSchema } from 'meteor/vulcan:core'; export const FILE = 'FSFile'; -addGraphQLSchema(` +if(Meteor.isServer) { + addGraphQLSchema(` type ${FILE} { _id: String name: String! @@ -22,3 +23,5 @@ addGraphQLSchema(` isPDF: Boolean } `); +} + diff --git a/lib/modules/index.js b/lib/modules/index.js index 4100eaf..a914b5a 100644 --- a/lib/modules/index.js +++ b/lib/modules/index.js @@ -1,4 +1,2 @@ -import './register'; - export * from './graphql'; export * from './components'; diff --git a/lib/modules/register.js b/lib/modules/register.js deleted file mode 100644 index 215d5b4..0000000 --- a/lib/modules/register.js +++ /dev/null @@ -1,5 +0,0 @@ -import { registerComponent } from 'meteor/vulcan:core'; - -import Upload from './containers/Upload'; - -registerComponent('Upload', Upload); diff --git a/lib/server/createEditHandler.js b/lib/server/createEditHandler.js deleted file mode 100644 index e558bee..0000000 --- a/lib/server/createEditHandler.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Readable } from 'stream'; - -import { addFileFromReadable } from './helpers'; - -const createEditHandler = (fieldName, FSCollection, getValue) => - async function editHandler(modifier, document, currentUser) { - if (modifier.$set && modifier.$set[fieldName]) { - const fieldValue = modifier.$set[fieldName]; - return fieldValue instanceof Readable - ? getValue(await addFileFromReadable(FSCollection, fieldValue)) - : fieldValue; - } - return undefined; - }; - -export default createEditHandler; diff --git a/lib/server/createEditHandlerMultiple.js b/lib/server/createEditHandlerMultiple.js deleted file mode 100644 index b4435d5..0000000 --- a/lib/server/createEditHandlerMultiple.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Readable } from 'stream'; -import get from 'lodash/get'; - -import { addFileFromReadable } from './helpers'; - -const createEditHandler = (fieldName, FSCollection, getValue) => - async function editHandler(modifier, document, currentUser) { - const fieldValue = get(modifier, ['$set', fieldName]); - if (Array.isArray(fieldValue)) { - return Promise.all( - fieldValue.map(async value => { - if (value instanceof Readable) { - return getValue(await addFileFromReadable(FSCollection, value)); - } - return Promise.resolve(value); - }), - ); - } - return undefined; - }; - -export default createEditHandler; diff --git a/lib/server/createHandlers.js b/lib/server/createHandlers.js deleted file mode 100644 index 4c1152f..0000000 --- a/lib/server/createHandlers.js +++ /dev/null @@ -1,47 +0,0 @@ -import createInsertHandler from './createInsertHandler'; -import createEditHandler from './createEditHandler'; -import createInsertHandlerMultiple from './createInsertHandlerMultiple'; -import createEditHandlerMultiple from './createEditHandlerMultiple'; - -/** - * Retrieves the id of the file document. - * - * @param {Object} file - * @return {String} - * @function createHandlers~defaultGetValue - */ -const defaultGetValue = file => file._id; - -/** - * Creates field's insert and edit handlers. - * - * @param {Object} options Options - * @param {String} options.fieldName - * Field name - * @param {String} options.FSCollection - * FSCollection where to store the uploaded files as documents - * @param {Boolean=} options.multiple=false - * Whether the field will be multiple or not - * @param {Function=} options.getValue=createHandlers~defaultGetValue - * Function used to retrieve the value that will be stored in the field from - * the file document. Default to {@link createHandlers~defaultGetValue} - * @return {{onInsert: Function, onEdit: Function}} - */ -export default function createHandlers(options) { - const { - fieldName, - FSCollection, - multiple = false, - getValue = defaultGetValue, - } = options; - - const curryOnInsert = multiple - ? createInsertHandlerMultiple - : createInsertHandler; - const curryOnEdit = multiple ? createEditHandlerMultiple : createEditHandler; - - return { - onInsert: curryOnInsert(fieldName, FSCollection, getValue), - onEdit: curryOnEdit(fieldName, FSCollection, getValue), - }; -} diff --git a/lib/server/createInsertHandler.js b/lib/server/createInsertHandler.js deleted file mode 100644 index f490af3..0000000 --- a/lib/server/createInsertHandler.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Readable } from 'stream'; - -import { addFileFromReadable } from './helpers'; - -const createInsertHandler = (fieldName, FSCollection, getValue) => - async function insertHandler(document, currentUser) { - const fieldValue = document[fieldName]; - if (fieldValue instanceof Readable) { - return getValue(await addFileFromReadable(FSCollection, fieldValue)); - } - return fieldValue; - }; - -export default createInsertHandler; diff --git a/lib/server/createInsertHandlerMultiple.js b/lib/server/createInsertHandlerMultiple.js deleted file mode 100644 index 9499eda..0000000 --- a/lib/server/createInsertHandlerMultiple.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Readable } from 'stream'; - -import { addFileFromReadable } from './helpers'; - -export default function createInsertHandlerMultiple( - fieldName, - FSCollection, - getValue, -) { - return async function insertHandlerMultiple(document, currentUser) { - const fieldValue = document[fieldName]; - if (Array.isArray(fieldValue)) { - return Promise.all( - fieldValue.map(async value => { - if (value instanceof Readable) { - return getValue(await addFileFromReadable(FSCollection, value)); - } - return Promise.resolve(value); - }), - ); - } - return fieldValue; - }; -} diff --git a/lib/server/createResolverMultiple.js b/lib/server/createResolverMultiple.js deleted file mode 100644 index d736f95..0000000 --- a/lib/server/createResolverMultiple.js +++ /dev/null @@ -1,16 +0,0 @@ -import castArray from 'lodash/castArray'; -import compact from 'lodash/compact'; - -export default function createResolverMultiple( - fieldName, - FSCollection, - resolveId, -) { - return async document => { - const fileIds = compact(castArray(document[fieldName])).map(resolveId); - if (fileIds.length === 0) return []; - - const files = await FSCollection.loader.loadMany(fileIds); - return compact(files); - }; -} diff --git a/lib/server/createResolverSingle.js b/lib/server/createResolverSingle.js deleted file mode 100644 index 2aadc00..0000000 --- a/lib/server/createResolverSingle.js +++ /dev/null @@ -1,17 +0,0 @@ -import createResolverMultiple from './createResolverMultiple'; - -export default function createResolverSingle( - fieldName, - FSCollection, - resolveId, -) { - const resolverMultiple = createResolverMultiple( - fieldName, - FSCollection, - resolveId, - ); - return async document => { - const files = await resolverMultiple(document); - return files[0] || null; - } -} diff --git a/lib/server/createResolver.js b/lib/server/fieldResolver.js similarity index 51% rename from lib/server/createResolver.js rename to lib/server/fieldResolver.js index a230ddd..cfae58b 100644 --- a/lib/server/createResolver.js +++ b/lib/server/fieldResolver.js @@ -1,9 +1,9 @@ -import createResolverMultiple from './createResolverMultiple'; -import createResolverSingle from './createResolverSingle'; -import defaultResolveId from '../modules/defaultResolveId'; +import defaultResolveId from "../modules/defaultResolveId"; +import castArray from "lodash/castArray"; +import compact from "lodash/compact"; /** - * Creates field's resolver. + * Creates field's resolver to retrieve the file. * * @param {Object} options Options * @param {String} options.fieldName @@ -29,3 +29,25 @@ export default function createResolver(options) { ? createResolverMultiple(fieldName, FSCollection, resolveId) : createResolverSingle(fieldName, FSCollection, resolveId); } + +function createResolverMultiple(fieldName, FSCollection, resolveId) { + return async (document) => { + const fileIds = compact(castArray(document[fieldName])).map(resolveId); + if (fileIds.length === 0) return []; + + const files = await FSCollection.loader.loadMany(fileIds); + return compact(files); + }; +} + +function createResolverSingle(fieldName, FSCollection, resolveId) { + const resolverMultiple = createResolverMultiple( + fieldName, + FSCollection, + resolveId + ); + return async (document) => { + const files = await resolverMultiple(document); + return files[0] || null; + }; +} diff --git a/lib/server/fieldUploadHandlers.js b/lib/server/fieldUploadHandlers.js new file mode 100644 index 0000000..de7f02e --- /dev/null +++ b/lib/server/fieldUploadHandlers.js @@ -0,0 +1,134 @@ +/** + * Create Vulcan field callbacks for file upload and edition + */ +import { Readable } from 'stream'; +import { addFileFromReadable } from './helpers'; + +const uploadFromField = async (getValue, FSCollection, fieldValue) => { + const file = await fieldValue + if (!file.createReadStream) return file + const addFileResult = await addFileFromReadable(FSCollection, file.createReadStream()) + return getValue(addFileResult) +} + + +/** + * Retrieves the id of the file document. + * + * @param {Object} file + * @return {String} + * @function createHandlers~defaultGetValue + */ +const defaultGetValue = file => file._id; + +/** + * Creates field's create and update handlers. + * + * @param {Object} options Options + * @param {String} options.fieldName + * Field name + * @param {String} options.FSCollection + * FSCollection where to store the uploaded files as documents + * @param {Boolean=} options.multiple=false + * Whether the field will be multiple or not + * @param {Function=} options.getValue=createHandlers~defaultGetValue + * Function used to retrieve the value that will be stored in the field from + * the file document. Default to {@link createHandlers~defaultGetValue} + * @return {{onCreate: Function, onUpdate: Function}} + */ +export default function createUploadHandlers(options) { + const { + fieldName, + FSCollection, + multiple = false, + getValue = defaultGetValue, + } = options; + + const curryOnCreate = multiple + ? createHandlerMultiple + : createHandler; + const curryOnUpdate = multiple ? updateHandlerMultiple : updateHandler; + + return { + onCreate: curryOnCreate(fieldName, FSCollection, getValue), + onUpdate: curryOnUpdate(fieldName, FSCollection, getValue), + }; +} + + +export const createHandler = (fieldName, FSCollection, getValue) => + async function createHandler({ newDocument, currentUser }) { + const fieldValue = newDocument[fieldName]; + if (fieldValue) { + return uploadFromField(getValue, FSCollection, fieldValue) + } + return undefined + }; + +export const createHandlerMultiple = ( + fieldName, + FSCollection, + getValue, +) => + async function createHandlerMultiple({ newDocument, currentUser }) { + const fieldValues = newDocument[fieldName]; + if (Array.isArray(fieldValue)) { + return uploadFromFields(getValue, FSCollection, fieldValues) + } + return fieldValues; + }; + + + +// if the user want to upload a file, the field will contain a promise +const hasUploadedFile = fieldValue => fieldValue && fieldValue.then +const isEmpty = fieldValue => fieldValue === null + +export const updateHandler = (fieldName, FSCollection, getValue) => + async function updateHandler({ data, document, currentUser, oldDocument }) { + const fieldValue = data[fieldName] + // null => file has been deleted or never existed in the first place + // fieldValue.then is defined => field is a Promise, a new file has been added + if (!(isEmpty(fieldValue) || hasUploadedFile(fieldValue))) return undefined // no change or deletion + // remove the old file (if appliable) whenever the field is modified (either updated to a new file or deleted) + if (oldDocument[fieldName]) { + // TODO: remove previous value + const fileId = oldDocument[fieldName] + await FSCollection.remove(fileId) + } + // update new file (if there is a new file) + if (fieldValue) { + return await uploadFromField(getValue, FSCollection, fieldValue) + } + return undefined; + }; + +const uploadFromFields = async (getValue, FSCollection, fieldValues) => { + return Promise.all( + fieldValues + .filter(fieldValue => !!fieldValue) // filter out non defined values + .map(async fieldValue => { + return uploadFromField(getValue, FSCollection, fieldValue) + }) + ); +} +export const updateHandlerMultiple = (fieldName, FSCollection, getValue) => + // TODO: not tested, not sure about the structure of "fieldValues" and how to detect deletion + // Implementation considers that if one file of the list is modified, then + // fieldValues will list all files + async function updateHandler({ data, document, currentUser }) { + const fieldValues = data[fieldName] + if (!(isEmpty(fieldValues) || _any(fieldValues, fieldValue => hasUploadedFile(fieldValue)))) return undefined // no change or deletion + // remove old files + if (oldDocument[fieldName]) { + const oldFileIds = oldDocument[fieldName] + await Promise.all(oldFileIds.map(FSCollection.remove)) + } + // create files + if (fieldValues) { + if (Array.isArray(fieldValues)) { + return uploadFromFields(getValue, FSCollection, fieldValues) + } + } + return undefined; + }; \ No newline at end of file diff --git a/lib/server/generateFieldSchema.js b/lib/server/generateFieldSchema.js index 9638ba6..5bff72a 100644 --- a/lib/server/generateFieldSchema.js +++ b/lib/server/generateFieldSchema.js @@ -1,8 +1,8 @@ import merge from 'lodash/merge'; import generateFieldSchemaBase from '../modules/generateFieldSchemaBase'; -import createHandlers from './createHandlers'; -import createResolver from './createResolver'; +import fieldUploadHandlers from './fieldUploadHandlers'; +import fieldResolver from './fieldResolver'; /** * Server's function to generate a field schema. What this basically does is to @@ -23,9 +23,9 @@ export default (options = {}) => { return generateFieldSchemaBase({ ...options, fieldSchema: merge({}, fieldSchema, { - ...createHandlers(options), + ...fieldUploadHandlers(options), // onCreate / onUpdate callbacks to upload the file resolveAs: { - resolver: createResolver(options), + resolver: fieldResolver(options), }, }), }); diff --git a/lib/server/main.js b/lib/server/main.js index e788584..4442bb6 100644 --- a/lib/server/main.js +++ b/lib/server/main.js @@ -1,4 +1,3 @@ -import './uploadMiddleware'; import './addGraphQLSchemaAndResolvers'; export * from '../modules'; diff --git a/lib/server/uploadMiddleware.js b/lib/server/uploadMiddleware.js deleted file mode 100644 index bfee311..0000000 --- a/lib/server/uploadMiddleware.js +++ /dev/null @@ -1,40 +0,0 @@ -import { webAppConnectHandlersUse } from 'meteor/vulcan:lib'; -import objectPath from 'object-path'; -import brackets2Dots from 'brackets2dots'; -import asyncBusboy from 'async-busboy'; - -function isUpload(req) { - return ( - req.headers['content-type'] && req.headers['content-type'].startsWith('multipart/form-data') - ); -} - -async function graphqlUploadMiddleware(req, res, next) { - try { - if (!isUpload(req)) { - return next(); - } - const { files, fields } = await asyncBusboy(req); - - // console.log(''); - // console.log('## graphqlUploadMiddleware ##########################################'); - - files.forEach(file => { - objectPath.set(fields, brackets2Dots(file.fieldname), file); - }); - req.body = fields.data; - - // console.log('files:', files); - // console.log('req:', req); - } catch (e) { - // console.error('error:', e); - return next(e); - } - return next(); -} - -Meteor.startup(() => { - webAppConnectHandlersUse('graphql-upload-middleware', '/graphql', graphqlUploadMiddleware, { - order: 1 - }); -}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..48e341a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3 @@ +{ + "lockfileVersion": 1 +} diff --git a/package.js b/package.js index 5a1b324..e594901 100644 --- a/package.js +++ b/package.js @@ -1,21 +1,22 @@ Package.describe({ - name: 'origenstudio:vulcan-files', - version: '1.0.0-alpha.1', - summary: 'Provides Vulcan with the capability of uploading files to server using Meteor-Files', - git: 'https://github.com/OrigenStudio/vulcan-files', - documentation: 'README.md' + name: "origenstudio:vulcan-files", + version: "1.0.0-alpha.1", + summary: + "Provides Vulcan with the capability of uploading files to server using Meteor-Files", + git: "https://github.com/OrigenStudio/vulcan-files", + documentation: "README.md", }); Package.onUse(api => { - api.versionsFrom('1.6.1'); + api.versionsFrom("1.6.1"); api.use([ - 'ecmascript', - 'vulcan:core@1.11.0', - 'ostrio:files@1.10.1', - 'origenstudio:files-helpers@1.0.0-alpha.1', + "ecmascript", + "vulcan:core@1.13.4", + "ostrio:files@1.10.1", + "origenstudio:files-helpers@1.0.0-alpha.1", ]); - api.mainModule('lib/server/main.js', 'server'); - api.mainModule('lib/client/main.js', 'client'); + api.mainModule("lib/server/main.js", "server"); + api.mainModule("lib/client/main.js", "client"); });