2016-11-26 11:33:27 +09:00
/ *
Main form component .
This component expects :
# # # All Forms :
- collection
- currentUser
- client ( Apollo client )
# # # New Form :
- newMutation
# # # Edit Form :
- editMutation
- removeMutation
- document
* /
2016-12-12 11:34:28 +09:00
import { Components , Utils } from 'meteor/nova:core' ;
2016-11-23 17:22:29 +09:00
import React , { PropTypes , Component } from 'react' ;
import { FormattedMessage , intlShape } from 'react-intl' ;
import Formsy from 'formsy-react' ;
import { Button } from 'react-bootstrap' ;
import Flash from "./Flash.jsx" ;
import FormGroup from "./FormGroup.jsx" ;
import { flatten , deepValue , getEditableFields , getInsertableFields } from './utils.js' ;
/ *
1. Constructor
2. Helpers
3. Errors
4. Context
4. Method & Callback
5. Render
* /
2016-12-08 23:48:16 +01:00
class Form extends Component {
2016-11-23 17:22:29 +09:00
// --------------------------------------------------------------------- //
// ----------------------------- Constructor --------------------------- //
// --------------------------------------------------------------------- //
constructor ( props ) {
super ( props ) ;
this . submitForm = this . submitForm . bind ( this ) ;
this . updateState = this . updateState . bind ( this ) ;
// this.methodCallback = this.methodCallback.bind(this);
2016-11-25 12:22:13 +09:00
this . newMutationSuccessCallback = this . newMutationSuccessCallback . bind ( this ) ;
this . editMutationSuccessCallback = this . editMutationSuccessCallback . bind ( this ) ;
2016-11-23 17:22:29 +09:00
this . mutationSuccessCallback = this . mutationSuccessCallback . bind ( this ) ;
this . mutationErrorCallback = this . mutationErrorCallback . bind ( this ) ;
this . addToAutofilledValues = this . addToAutofilledValues . bind ( this ) ;
this . throwError = this . throwError . bind ( this ) ;
this . clearForm = this . clearForm . bind ( this ) ;
2017-01-23 15:50:55 +01:00
this . updateCurrentValues = this . updateCurrentValues . bind ( this ) ;
2016-11-23 17:22:29 +09:00
this . formKeyDown = this . formKeyDown . bind ( this ) ;
this . deleteDocument = this . deleteDocument . bind ( this ) ;
// a debounced version of seState that only updates state every 500 ms (not used)
this . debouncedSetState = _ . debounce ( this . setState , 500 ) ;
this . state = {
disabled : false ,
errors : [ ] ,
2017-01-04 09:36:56 +01:00
autofilledValues : ( props . formType === 'new' && props . prefilledProps ) || { } ,
2016-11-23 17:22:29 +09:00
currentValues : { }
} ;
}
// --------------------------------------------------------------------- //
// ------------------------------- Helpers ----------------------------- //
// --------------------------------------------------------------------- //
// return the current schema based on either the schema or collection prop
getSchema ( ) {
2016-12-12 11:34:28 +09:00
return this . props . schema ? this . props . schema : Utils . stripTelescopeNamespace ( this . props . collection . simpleSchema ( ) . _schema ) ;
2016-11-23 17:22:29 +09:00
}
getFieldGroups ( ) {
const schema = this . getSchema ( ) ;
// build fields array by iterating over the list of field names
let fields = this . getFieldNames ( ) . map ( fieldName => {
// get schema for the current field
const fieldSchema = schema [ fieldName ] ;
fieldSchema . name = fieldName ;
2017-02-08 10:48:17 +01:00
2016-11-23 17:22:29 +09:00
// intialize properties
let field = {
name : fieldName ,
datatype : fieldSchema . type ,
control : fieldSchema . control ,
layout : this . props . layout ,
order : fieldSchema . order
}
2017-02-08 10:48:17 +01:00
// hide or show the field, a function taking form props as argument & returning a boolean can be used
field . hidden = ( typeof fieldSchema . hidden === 'function' ) ? ! ! fieldSchema . hidden . call ( fieldSchema , this . props ) : fieldSchema . hidden ;
2016-11-23 17:22:29 +09:00
// add label or internationalized field name if necessary (field not hidden)
if ( ! field . hidden ) {
field . label = fieldSchema . label ? fieldSchema . label : this . context . intl . formatMessage ( { id : this . props . collection . _name + "." + fieldName } ) ;
}
// add value
field . value = this . getDocument ( ) && deepValue ( this . getDocument ( ) , fieldName ) ? deepValue ( this . getDocument ( ) , fieldName ) : "" ;
2016-12-12 09:55:24 +09:00
// if value is an array of objects ({_id: '123'}, {_id: 'abc'}), flatten it into an array of strings (['123', 'abc'])
2017-01-13 09:21:07 +01:00
// fallback to item itself if item._id is not defined (ex: item is not an object or item is just {slug: 'xxx'})
2016-12-12 09:55:24 +09:00
if ( Array . isArray ( field . value ) ) {
2017-01-13 09:21:07 +01:00
field . value = field . value . map ( item => item . _id || item ) ;
2016-12-12 09:55:24 +09:00
}
2016-11-23 17:22:29 +09:00
// backward compatibility from 'autoform' to 'form'
if ( fieldSchema . autoform ) {
fieldSchema . form = fieldSchema . autoform ;
2016-11-26 02:46:55 +08:00
console . warn ( ` 🔭 Telescope Nova Warning: The 'autoform' field is deprecated. You should rename it to 'form' instead. It was defined on your ' ${ fieldName } ' field on the ' ${ this . props . collection . _name } ' collection ` ) ; // eslint-disable-line
2016-11-23 17:22:29 +09:00
}
// replace value by prefilled value if value is empty
if ( fieldSchema . form && fieldSchema . form . prefill ) {
const prefilledValue = typeof fieldSchema . form . prefill === "function" ? fieldSchema . form . prefill . call ( fieldSchema ) : fieldSchema . form . prefill ;
if ( ! ! prefilledValue && ! field . value ) {
field . prefilledValue = prefilledValue ;
field . value = prefilledValue ;
}
}
2016-11-26 02:46:55 +08:00
2016-11-23 17:22:29 +09:00
// replace empty value, which has not been prefilled, by the default value from the schema
if ( fieldSchema . defaultValue && field . value === "" ) {
field . value = fieldSchema . defaultValue ;
}
// add options if they exist
if ( fieldSchema . form && fieldSchema . form . options ) {
field . options = typeof fieldSchema . form . options === "function" ? fieldSchema . form . options . call ( fieldSchema , this . props ) : fieldSchema . form . options ;
}
if ( fieldSchema . form && fieldSchema . form . disabled ) {
field . disabled = typeof fieldSchema . form . disabled === "function" ? fieldSchema . form . disabled . call ( fieldSchema ) : fieldSchema . form . disabled ;
}
if ( fieldSchema . form && fieldSchema . form . help ) {
field . help = typeof fieldSchema . form . help === "function" ? fieldSchema . form . help . call ( fieldSchema ) : fieldSchema . form . help ;
}
// add placeholder
if ( fieldSchema . form && fieldSchema . form . placeholder ) {
field . placeholder = fieldSchema . form . placeholder ;
}
if ( fieldSchema . beforeComponent ) field . beforeComponent = fieldSchema . beforeComponent ;
if ( fieldSchema . afterComponent ) field . afterComponent = fieldSchema . afterComponent ;
// add group
if ( fieldSchema . group ) {
field . group = fieldSchema . group ;
}
// add document
field . document = this . getDocument ( ) ;
return field ;
} ) ;
// remove fields where hidden is set to true
fields = _ . reject ( fields , field => field . hidden ) ;
fields = _ . sortBy ( fields , "order" ) ;
2016-12-12 09:55:24 +09:00
// get list of all unique groups (based on their name) used in current fields
let groups = _ . compact ( _ . unique ( _ . pluck ( fields , "group" ) , false , g => g && g . name ) ) ;
2016-11-23 17:22:29 +09:00
// for each group, add relevant fields
groups = groups . map ( group => {
group . label = group . label || this . context . intl . formatMessage ( { id : group . name } ) ;
group . fields = _ . filter ( fields , field => { return field . group && field . group . name === group . name } ) ;
return group ;
} ) ;
// add default group
groups = [ {
name : "default" ,
label : "default" ,
order : 0 ,
fields : _ . filter ( fields , field => { return ! field . group ; } )
} ] . concat ( groups ) ;
// sort by order
groups = _ . sortBy ( groups , "order" ) ;
// console.log(groups);
return groups ;
}
// if a document is being passed, this is an edit form
getFormType ( ) {
return this . props . document ? "edit" : "new" ;
}
// get relevant fields
getFieldNames ( ) {
const fields = this . props . fields ;
// get all editable/insertable fields (depending on current form type)
let relevantFields = this . getFormType ( ) === "edit" ? getEditableFields ( this . getSchema ( ) , this . props . currentUser , this . getDocument ( ) ) : getInsertableFields ( this . getSchema ( ) , this . props . currentUser ) ;
// if "fields" prop is specified, restrict list of fields to it
if ( typeof fields !== "undefined" && fields . length > 0 ) {
relevantFields = _ . intersection ( relevantFields , fields ) ;
}
return relevantFields ;
}
// for each field, we apply the following logic:
// - if its value is currently being inputted, use that
// - else if its value was provided by the db, use that (i.e. props.document)
// - else if its value is provded by the autofilledValues object, use that
getDocument ( ) {
const currentDocument = _ . clone ( this . props . document ) || { } ;
const document = Object . assign ( _ . clone ( this . state . autofilledValues ) , currentDocument , _ . clone ( this . state . currentValues ) ) ;
return document ;
}
// NOTE: this is not called anymore since we're updating on blur, not on change
// whenever the form changes, update its state
updateState ( e ) {
// e can sometimes be event, sometims be currentValue
// see https://github.com/christianalfoni/formsy-react/issues/203
if ( e . stopPropagation ) {
e . stopPropagation ( ) ;
} else {
// get rid of empty fields
_ . forEach ( e , ( value , key ) => {
if ( _ . isEmpty ( value ) ) {
delete e [ key ] ;
}
} ) ;
2017-02-02 15:15:51 +01:00
this . setState ( prevState => ( {
2016-11-23 17:22:29 +09:00
currentValues : e
2017-02-02 15:15:51 +01:00
} ) ) ;
2016-11-23 17:22:29 +09:00
}
}
2017-01-23 15:50:55 +01:00
// manually update the current values of one or more fields(i.e. on blur). See above for on change instead
updateCurrentValues ( newValues ) {
2017-02-02 15:15:51 +01:00
// keep the previous ones and extend (with possible replacement) with new ones
2017-01-23 15:50:55 +01:00
this . setState ( prevState => ( {
2017-02-02 15:15:51 +01:00
currentValues : {
... prevState . currentValues ,
... newValues ,
}
2017-01-23 15:50:55 +01:00
} ) ) ;
2016-11-23 17:22:29 +09:00
}
// key down handler
formKeyDown ( event ) {
if ( ( event . ctrlKey || event . metaKey ) && event . keyCode === 13 ) {
this . submitForm ( this . refs . form . getModel ( ) ) ;
}
}
// --------------------------------------------------------------------- //
// ------------------------------- Errors ------------------------------ //
// --------------------------------------------------------------------- //
// clear and re-enable the form
// by default, clear errors and keep current values
clearForm ( { clearErrors = true , clearCurrentValues = false } ) {
this . setState ( prevState => ( {
errors : clearErrors ? [ ] : prevState . errors ,
currentValues : clearCurrentValues ? { } : prevState . currentValues ,
disabled : false ,
} ) ) ;
}
// render errors
renderErrors ( ) {
2017-02-02 15:15:51 +01:00
return < div className = "form-errors" > { this . state . errors . map ( ( message , index ) => < Flash key = { index } message = { message } / > ) } < / div >
2016-11-23 17:22:29 +09:00
}
// --------------------------------------------------------------------- //
// ------------------------------- Context ----------------------------- //
// --------------------------------------------------------------------- //
2017-02-02 15:15:51 +01:00
// add error to form state
// from "GraphQL Error: You have an error [error_code]"
// to { content: "You have an error", type: "error" }
throwError ( errorMessage ) {
let strippedError = errorMessage ;
// strip the "GraphQL Error: message [error_code]" given by Apollo if present
const graphqlPrefixIsPresent = strippedError . match ( /GraphQL error: (.*)/ ) ;
if ( graphqlPrefixIsPresent ) {
strippedError = graphqlPrefixIsPresent [ 1 ] ;
}
// strip the error code if present
const errorCodeIsPresent = strippedError . match ( /(.*)\[(.*)\]/ ) ;
if ( errorCodeIsPresent ) {
strippedError = errorCodeIsPresent [ 1 ] ;
}
// internationalize the error if necessary
2017-02-11 12:27:32 +01:00
const intlError = Utils . decodeIntlError ( strippedError , { stripped : true } ) ;
2017-02-02 15:15:51 +01:00
if ( typeof intlError === 'object' ) {
const { id , value = "" } = intlError ;
strippedError = this . context . intl . formatMessage ( { id } , { value } ) ;
}
// build the error for the Flash component and only keep the interesting message
const error = {
content : strippedError ,
type : 'error'
} ;
// update the state with unique errors messages
this . setState ( prevState => ( {
errors : _ . uniq ( [ ... prevState . errors , error ] )
} ) ) ;
2016-11-23 17:22:29 +09:00
}
// add something to prefilled values
addToAutofilledValues ( property ) {
2017-02-02 15:15:51 +01:00
this . setState ( prevState => ( {
autofilledValues : {
... prevState . autofilledValues ,
... property
}
} ) ) ;
2016-11-23 17:22:29 +09:00
}
// pass on context to all child components
getChildContext ( ) {
return {
throwError : this . throwError ,
2017-02-02 15:15:51 +01:00
clearForm : this . clearForm ,
2016-11-23 17:22:29 +09:00
autofilledValues : this . state . autofilledValues ,
addToAutofilledValues : this . addToAutofilledValues ,
2017-01-23 15:50:55 +01:00
updateCurrentValues : this . updateCurrentValues ,
2016-11-23 17:22:29 +09:00
getDocument : this . getDocument ,
} ;
}
// --------------------------------------------------------------------- //
// ------------------------------- Method ------------------------------ //
// --------------------------------------------------------------------- //
2016-11-25 12:22:13 +09:00
newMutationSuccessCallback ( result ) {
this . mutationSuccessCallback ( result , 'new' ) ;
}
editMutationSuccessCallback ( result ) {
this . mutationSuccessCallback ( result , 'edit' ) ;
}
mutationSuccessCallback ( result , mutationType ) {
2016-11-23 17:22:29 +09:00
const document = result . data [ Object . keys ( result . data ) [ 0 ] ] ; // document is always on first property
2016-11-25 12:22:13 +09:00
// for new mutation, run refetch function if it exists
if ( mutationType === 'new' && this . props . refetch ) this . props . refetch ( ) ;
2017-01-13 18:17:08 +01:00
// call the clear form method (i.e. trigger setState) only if the form has not been unmounted (we are in an async callback, everything can happen!)
if ( typeof this . refs . form !== 'undefined' ) {
let clearCurrentValues = false ;
// reset form if this is a new document form
if ( this . props . formType === "new" ) {
this . refs . form . reset ( ) ;
clearCurrentValues = true ;
}
this . clearForm ( { clearErrors : true , clearCurrentValues } ) ;
}
2017-01-10 17:49:03 +09:00
2016-11-23 17:22:29 +09:00
// run success callback if it exists
if ( this . props . successCallback ) this . props . successCallback ( document ) ;
}
// catch graphql errors
mutationErrorCallback ( error ) {
2017-02-02 15:15:51 +01:00
this . setState ( prevState => ( { disabled : false } ) ) ;
2016-11-23 17:22:29 +09:00
2017-02-02 15:15:51 +01:00
console . log ( "// graphQL Error" ) ; // eslint-disable-line no-console
console . log ( error ) ; // eslint-disable-line no-console
2016-11-23 17:22:29 +09:00
if ( ! _ . isEmpty ( error ) ) {
// add error to state
2017-02-02 15:15:51 +01:00
this . throwError ( error . message ) ;
2016-11-23 17:22:29 +09:00
}
// note: we don't have access to the document here :( maybe use redux-forms and get it from the store?
// run error callback if it exists
// if (this.props.errorCallback) this.props.errorCallback(document, error);
}
// submit form handler
submitForm ( data ) {
2017-02-02 15:15:51 +01:00
this . setState ( prevState => ( { disabled : true } ) ) ;
2016-11-23 17:22:29 +09:00
// complete the data with values from custom components which are not being catched by Formsy mixin
2016-12-20 09:27:16 +09:00
// note: it follows the same logic as SmartForm's getDocument method
2016-11-26 02:46:55 +08:00
data = {
2017-02-02 15:15:51 +01:00
... this . state . autofilledValues , // ex: can be values from NewsletterSubscribe component
2016-11-23 17:22:29 +09:00
... data , // original data generated thanks to Formsy
... this . state . currentValues , // ex: can be values from DateTime component
} ;
const fields = this . getFieldNames ( ) ;
// if there's a submit callback, run it
if ( this . props . submitCallback ) {
data = this . props . submitCallback ( data ) ;
}
if ( this . props . formType === "new" ) { // new document form
// remove any empty properties
let document = _ . compactObject ( flatten ( data ) ) ;
// call method with new document
2016-11-25 12:22:13 +09:00
this . props . newMutation ( { document } ) . then ( this . newMutationSuccessCallback ) . catch ( this . mutationErrorCallback ) ;
2016-11-23 17:22:29 +09:00
} else { // edit document form
const document = this . getDocument ( ) ;
// put all keys with data on $set
const set = _ . compactObject ( flatten ( data ) ) ;
// put all keys without data on $unset
const unsetKeys = _ . difference ( fields , _ . keys ( set ) ) ;
const unset = _ . object ( unsetKeys , unsetKeys . map ( ( ) => true ) ) ;
// build modifier
const modifier = { $set : set } ;
if ( ! _ . isEmpty ( unset ) ) modifier . $unset = unset ;
// call method with _id of document being edited and modifier
// Meteor.call(this.props.methodName, document._id, modifier, this.methodCallback);
2016-11-25 12:22:13 +09:00
this . props . editMutation ( { documentId : document . _id , set : set , unset : unset } ) . then ( this . editMutationSuccessCallback ) . catch ( this . mutationErrorCallback ) ;
2016-11-23 17:22:29 +09:00
}
}
deleteDocument ( ) {
const document = this . getDocument ( ) ;
2016-11-25 12:22:13 +09:00
const documentId = this . props . document . _id ;
2016-11-27 08:39:25 +09:00
const documentTitle = document . title || document . name || '' ;
2016-11-23 17:22:29 +09:00
const deleteDocumentConfirm = this . context . intl . formatMessage ( { id : ` ${ this . props . collection . _name } .delete_confirm ` } , { title : documentTitle } ) ;
2016-12-08 23:48:16 +01:00
if ( window . confirm ( deleteDocumentConfirm ) ) {
2016-11-23 17:22:29 +09:00
this . props . removeMutation ( { documentId } )
. then ( ( mutationResult ) => { // the mutation result looks like {data:{collectionRemove: null}} if succeeded
2016-11-24 15:47:51 +09:00
if ( this . props . removeSuccessCallback ) this . props . removeSuccessCallback ( { documentId , documentTitle } ) ;
2016-11-25 12:22:13 +09:00
if ( this . props . refetch ) this . props . refetch ( ) ;
2016-11-23 17:22:29 +09:00
} )
2016-11-24 15:47:51 +09:00
. catch ( ( error ) => {
console . log ( error ) ;
2016-11-23 17:22:29 +09:00
} ) ;
}
}
// --------------------------------------------------------------------- //
// ------------------------- Lifecycle Hooks --------------------------- //
// --------------------------------------------------------------------- //
render ( ) {
const fieldGroups = this . getFieldGroups ( ) ;
const collectionName = this . props . collection . _name ;
return (
< div className = { "document-" + this . props . formType } >
< Formsy.Form
onSubmit = { this . submitForm }
onKeyDown = { this . formKeyDown }
disabled = { this . state . disabled }
ref = "form"
>
{ this . renderErrors ( ) }
2017-01-23 15:50:55 +01:00
{ fieldGroups . map ( group => < FormGroup key = { group . name } { ...group } updateCurrentValues = { this . updateCurrentValues } / > ) }
2016-11-23 17:22:29 +09:00
< Button type = "submit" bsStyle = "primary" > < FormattedMessage id = "forms.submit" / > < / Button >
{ this . props . cancelCallback ? < a className = "form-cancel" onClick = { this . props . cancelCallback } > < FormattedMessage id = "forms.cancel" / > < / a > : null }
< / Formsy.Form >
{
this . props . formType === 'edit' && this . props . showRemove
? < div >
< hr / >
< a onClick = { this . deleteDocument } className = { ` ${ collectionName } -delete-link ` } >
2016-12-06 18:06:29 +01:00
< Components.Icon name = "close" / > < FormattedMessage id = { ` ${ collectionName } .delete ` } / >
2016-11-23 17:22:29 +09:00
< / a >
< / div >
: null
}
< / div >
)
}
}
2016-12-08 23:48:16 +01:00
Form . propTypes = {
2016-11-23 17:22:29 +09:00
// main options
2017-02-02 15:15:51 +01:00
collection : PropTypes . object ,
document : PropTypes . object , // if a document is passed, this will be an edit form
schema : PropTypes . object , // usually not needed
2016-11-23 17:22:29 +09:00
// graphQL
2017-02-02 15:15:51 +01:00
newMutation : PropTypes . func , // the new mutation
editMutation : PropTypes . func , // the edit mutation
removeMutation : PropTypes . func , // the remove mutation
2016-11-23 17:22:29 +09:00
// form
2017-02-02 15:15:51 +01:00
prefilledProps : PropTypes . object ,
layout : PropTypes . string ,
fields : PropTypes . arrayOf ( PropTypes . string ) ,
showRemove : PropTypes . bool ,
2016-11-23 17:22:29 +09:00
// callbacks
2017-02-02 15:15:51 +01:00
submitCallback : PropTypes . func ,
successCallback : PropTypes . func ,
removeSuccessCallback : PropTypes . func ,
errorCallback : PropTypes . func ,
cancelCallback : PropTypes . func ,
currentUser : PropTypes . object ,
client : PropTypes . object ,
2016-11-23 17:22:29 +09:00
}
2016-12-08 23:48:16 +01:00
Form . defaultProps = {
2016-11-23 17:22:29 +09:00
layout : "horizontal" ,
}
2016-12-08 23:48:16 +01:00
Form . contextTypes = {
2016-11-23 17:22:29 +09:00
intl : intlShape
}
2016-12-08 23:48:16 +01:00
Form . childContextTypes = {
2017-02-02 15:15:51 +01:00
autofilledValues : PropTypes . object ,
addToAutofilledValues : PropTypes . func ,
updateCurrentValues : PropTypes . func ,
throwError : PropTypes . func ,
clearForm : PropTypes . func ,
getDocument : PropTypes . func
2016-11-23 17:22:29 +09:00
}
2016-12-08 23:48:16 +01:00
module . exports = Form