From c3f33cb7e0d623c89526b980904d35beaea0b7ad Mon Sep 17 00:00:00 2001 From: Erik Schannen Date: Tue, 8 May 2018 20:09:42 -0400 Subject: [PATCH] Changes to SmartForm behaviour - Moved UI portions of FormComponent to FormComponentInner.jsx in vulcan-ui-bootstrap - Added user alert when the user navigates to another route while there are unsaved changed in the form (disabled by default) - Added setting forms.warnUnsavedChanges and SmartForm property warnUnsavedChanges to enable user alert - Added optional Revert button in FormSubmits to allow the user to discard any changes to the form; this is activated by passing a "revertCallback" property to SmartForm (which can be empty: () => {}) - Added two functions that form components can access in the child context: refetchForm() to refetch the document from the database (in case it was updated by a background process), isChanged() to determine if there are any unsaved changes - For any phrases I have added to en_US.js I also added it to es_ES.js and fr_FR.js with the comment // TODO: translate - Updated Form.clearForm and Form.mutationSuccessCallback so that the user can continue working on the document after submitting it - The form now scrolls the flash message into view when a submit results in errors - Fixed bugs in FormComponent.shouldComponentUpdate() and Form.getDocument() - Fixed bug in FormComponent.handleChange() - number fields could not be cleared, only set to 0 - Fixed a bug in FormComponent.getValue() - it returned the initial value of a checkbox even after it was set to false, and a number even after it was set to 0 --- packages/vulcan-forms/lib/components/Form.jsx | 139 ++++++-- .../lib/components/FormComponent.jsx | 298 ++++++------------ .../vulcan-forms/lib/components/FormGroup.jsx | 12 +- .../lib/components/FormSubmit.jsx | 28 +- .../lib/components/FormWrapper.jsx | 10 +- packages/vulcan-i18n-en-us/lib/en_US.js | 4 +- packages/vulcan-i18n-es-es/lib/es_ES.js | 2 + packages/vulcan-i18n-fr-fr/lib/fr_FR.js | 2 + packages/vulcan-lib/lib/modules/components.js | 25 ++ packages/vulcan-lib/lib/modules/utils.js | 11 +- .../components/forms/FormComponentInner.jsx | 208 ++++++++++++ .../lib/modules/components.js | 1 + 12 files changed, 506 insertions(+), 234 deletions(-) create mode 100644 packages/vulcan-ui-bootstrap/lib/components/forms/FormComponentInner.jsx diff --git a/packages/vulcan-forms/lib/components/Form.jsx b/packages/vulcan-forms/lib/components/Form.jsx index 1837cf1f8..34c8cf45d 100644 --- a/packages/vulcan-forms/lib/components/Form.jsx +++ b/packages/vulcan-forms/lib/components/Form.jsx @@ -22,7 +22,10 @@ This component expects: */ -import { registerComponent, Components, runCallbacks, getCollection, getErrors } from 'meteor/vulcan:core'; +import { + registerComponent, Components, runCallbacks, getCollection, + getErrors, registerSetting, getSetting, Utils +} from 'meteor/vulcan:core'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; @@ -34,8 +37,14 @@ import unset from 'lodash/unset'; import compact from 'lodash/compact'; import update from 'lodash/update'; import merge from 'lodash/merge'; +import find from 'lodash/find'; +import isEqualWith from 'lodash/isEqualWith'; + import { convertSchema, formProperties } from '../modules/schema_utils'; +registerSetting('forms.warnUnsavedChanges', false, + 'Warn user about unsaved changes before leaving route', true); + // unsetCompact const unsetCompact = (object, path) => { const parentPath = path.slice(0, path.lastIndexOf('.')); @@ -68,7 +77,7 @@ const computeStateFromProps = (nextProps) => { */ class Form extends Component { - constructor(props) { + constructor (props) { super(props); this.state = { @@ -119,7 +128,18 @@ class Form extends Component { */ getDocument = () => { - const document = merge({}, this.state.initialDocument, this.defaultValues, this.state.currentValues); + const deletedValues = {}; + this.state.deletedValues.forEach(path => { + set(deletedValues, path, null); + }); + + const document = merge( + {}, + this.state.initialDocument, + this.defaultValues, + this.state.currentValues, + deletedValues + ); return document; }; @@ -223,9 +243,10 @@ class Form extends Component { // remove all hidden fields if (excludeHiddenFields) { + const document = this.getDocument(); relevantFields = _.reject(relevantFields, fieldName => { const hidden = schema[fieldName].hidden; - return typeof hidden === 'function' ? hidden(this.props) : hidden; + return typeof hidden === 'function' ? hidden({ ...this.props, document }) : hidden; }); } @@ -404,7 +425,11 @@ class Form extends Component { return { throwError: this.throwError, clearForm: this.clearForm, - submitForm: this.submitFormContext, //Change in name because we already have a function called submitForm, but no reason for the user to know about that + refetchForm: this.refetchForm, + isChanged: this.isChanged, + submitForm: this.submitFormContext, //Change in name because we already have a function + // called submitForm, but no reason for the user to know + // about that addToDeletedValues: this.addToDeletedValues, updateCurrentValues: this.updateCurrentValues, getDocument: this.getDocument, @@ -428,7 +453,9 @@ class Form extends Component { } /* + Manually update the current values of one or more fields(i.e. on change or blur). + */ updateCurrentValues = newValues => { // keep the previous ones and extend (with possible replacement) with new ones @@ -450,22 +477,93 @@ class Form extends Component { return newState; }); }; - + /* + + Warn the user if there are unsaved changes + + */ + handleRouteLeave = () => { + if (this.isChanged()) { + const message = this.context.intl.formatMessage({ + id: 'forms.confirm_discard', + defaultMessage: 'Are you sure you want to discard your changes?' + }); + return message; + } + }; + + /* + + Install a route leave hook to warn the user if there are unsaved changes + + */ + componentDidMount = () => { + let warnUnsavedChanges = getSetting('forms.warnUnsavedChanges'); + if (typeof this.props.warnUnsavedChanges === 'boolean') { + warnUnsavedChanges = this.props.warnUnsavedChanges; + } + if (warnUnsavedChanges) { + const routes = this.props.router.routes; + const currentRoute = routes[routes.length - 1]; + this.props.router.setRouteLeaveHook(currentRoute, this.handleRouteLeave); + } + }; + + /* + + Returns true if there are any differences between the initial document and the current one + + */ + isChanged = () => { + const initialDocument = this.state.initialDocument; + const changedDocument = this.getDocument(); + + const changedValue = find(changedDocument, (value, key, collection) => { + return !isEqualWith(value, initialDocument[key], (objValue, othValue) => { + if (!objValue && !othValue) return true; + }); + }); + + return typeof changedValue !== 'undefined'; + }; + + /* + + Refetch the document from the database (in case it was updated by another process or to reset the form) + + */ + refetchForm = () => { + if (this.props.data && this.props.data.refetch) { + this.props.data.refetch(); + } + }; + + /* + Clear and reset the form By default, clear errors and keep current values and deleted values */ - clearForm = ({ clearErrors = true, clearCurrentValues = false, clearDeletedValues = false }) => { + clearForm = ({ + clearErrors = true, + clearCurrentValues = false, + clearDeletedValues = false, + document, + }) => { + document = document ? merge({}, this.props.prefilledProps, document) : null; + this.setState(prevState => ({ errors: clearErrors ? [] : prevState.errors, currentValues: clearCurrentValues ? {} : prevState.currentValues, deletedValues: clearDeletedValues ? [] : prevState.deletedValues, + initialDocument: document ? document : prevState.initialDocument, disabled: false, })); }; /* + Key down handler */ @@ -489,15 +587,11 @@ class Form extends Component { // for new mutation, run refetch function if it exists if (mutationType === 'new' && this.props.refetch) this.props.refetch(); - // 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!) + // 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.getFormType() === 'new') { - this.refs.form.reset(); - clearCurrentValues = true; - } - this.clearForm({ clearErrors: true, clearCurrentValues, clearDeletedValues: true }); + this.refs.form.reset(); + this.clearForm({ clearErrors: true, clearCurrentValues: true, clearDeletedValues: true, document }); } // run document through mutation success callbacks @@ -523,9 +617,11 @@ class Form extends Component { // add error to state this.throwError(error); } - + // run error callback if it exists if (this.props.errorCallback) this.props.errorCallback(document, error); + + Utils.scrollIntoView('.flash-message'); }; /* @@ -632,14 +728,14 @@ class Form extends Component { // ----------------------------- Render -------------------------------- // // --------------------------------------------------------------------- // - render() { + render () { const fieldGroups = this.getFieldGroups(); const collectionName = this.getCollection()._name; return (
- + {fieldGroups.map(group => ( { // if value is an empty string, delete the field if (value === '') { value = null; } // if this is a number field, convert value before sending it up to Form - if (this.getType() === 'number') { + if (this.getType() === 'number' && value != null) { value = Number(value); } this.props.updateCurrentValues({ [this.props.path]: value }); @@ -50,17 +64,15 @@ class FormComponent extends Component { }; /* - - Note: not currently used because when function is debounced - some changes might not register if the user submits form too soon - + + Updates the state of charsCount and charsRemaining as the users types + */ - handleChangeDebounced = debounce(this.handleChange, 500, { leading: true }); - updateCharacterCount = value => { const characterCount = value ? value.length : 0; this.setState({ charsRemaining: this.props.max - characterCount, + charsCount: characterCount, }); }; @@ -88,7 +100,15 @@ class FormComponent extends Component { value = merge({}, documentValue, currentValue); } else { // note: value has to default to '' to make component controlled - value = currentValue || documentValue || ''; + //value = currentValue || documentValue || ''; + // note: the previous line does not work when a checkbox is 'false' or a number is '0' + value = currentValue; + if (typeof value === 'undefined' || value === null) { + value = documentValue; + } + if (typeof value === 'undefined' || value === null) { + value = ''; + } } // replace empty value, which has not been prefilled, by the default value from the schema if (isEmptyValue(value)) { @@ -114,8 +134,9 @@ class FormComponent extends Component { Get errors from Form state through context */ - getErrors = () => { - const fieldErrors = this.props.errors.filter(error => error.path === this.props.path); + getErrors = (errors) => { + errors = errors || this.props.errors; + const fieldErrors = errors.filter(error => error.path === this.props.path); return fieldErrors; }; @@ -129,194 +150,49 @@ class FormComponent extends Component { const p = props || this.props; const fieldType = p.datatype && p.datatype[0].type; const autoType = - fieldType === Number ? 'number' : fieldType === Boolean ? 'checkbox' : fieldType === Date ? 'date' : 'text'; + fieldType === Number ? 'number' : + fieldType === Boolean ? 'checkbox' : + fieldType === Date ? + 'date' : + 'text'; return p.input || autoType; }; - renderComponent() { - const { - input, - beforeComponent, - afterComponent, - options, - name, - label, - formType, - /* - - note: following properties will be passed as part of `...this.props` in `properties`: - - */ - // throwError, - // updateCurrentValues, - // currentValues, - // addToDeletedValues, - // deletedValues, - // clearFieldErrors, - // currentUser, - } = this.props; - - const value = this.getValue(); - const errors = this.getErrors(); - - // these properties are whitelisted so that they can be safely passed to the actual form input - // and avoid https://facebook.github.io/react/warnings/unknown-prop.html warnings - const inputProperties = { - name, - options, - label, - onChange: this.handleChange, - value, - ...this.props.inputProperties, - }; - - // note: we also pass value on props directly - const properties = { - ...this.props, - value, - errors, // only get errors for the current field - inputProperties, - }; - - // if input is a React component, use it - if (typeof input === 'function') { - const InputComponent = input; - return ; - } else { - // else pick a predefined component - - switch (this.getType()) { - case 'nested': - return ; - - case 'number': - return ; - - case 'url': - return ; - - case 'email': - return ; - - case 'textarea': - return ; - - case 'checkbox': - // formsy-react-components expects a boolean value for checkbox - // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/checkbox.js#L20 - properties.inputProperties.value = !!properties.inputProperties.value; - return ; - - case 'checkboxgroup': - // formsy-react-components expects an array value - // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/checkbox-group.js#L42 - if (!Array.isArray(properties.inputProperties.value)) { - properties.inputProperties.value = [properties.inputProperties.value]; - } - // in case of checkbox groups, check "checked" option to populate value if this is a "new document" form - const checkedValues = _.where(properties.options, { checked: true }).map(option => option.value); - if (checkedValues.length && !properties.inputProperties.value && formType === 'new') { - properties.inputProperties.value = checkedValues; - } - return ; - - case 'radiogroup': - // TODO: remove this? - // formsy-react-compnents RadioGroup expects an onChange callback - // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/radio-group.js#L33 - // properties.onChange = (name, value) => { - // this.context.updateCurrentValues({ [name]: value }); - // }; - return ; - - case 'select': - const noneOption = { - label: this.context.intl.formatMessage({ id: 'forms.select_option' }), - value: '', - disabled: true, - }; - properties.inputProperties.options = [noneOption, ...properties.inputProperties.options]; - - return ; - - case 'selectmultiple': - properties.inputProperties.multiple = true; - return ; - - case 'datetime': - return ; - - case 'date': - return ; - - case 'time': - return ; - - case 'text': - return ; - - default: - const CustomComponent = Components[input]; - return CustomComponent ? ( - - ) : ( - - ); - } - } - } - - showClear = () => { - return ['datetime', 'time', 'select', 'radiogroup'].includes(this.props.input); - }; - - clearField = e => { - e.preventDefault(); + /* + + Function passed to form controls to clear their contents (set their value to null) + + */ + clearField = event => { + event.preventDefault(); + event.stopPropagation(); this.props.updateCurrentValues({ [this.props.path]: null }); + if (this.showCharsRemaining()) { + this.updateCharacterCount(null); + } }; - renderClear() { + render () { return ( - - - + ); } - render() { - const { beforeComponent, afterComponent, name, input } = this.props; - - const hasErrors = this.getErrors() && this.getErrors().length; - const inputName = typeof input === 'function' ? input.name : input; - const inputClass = classNames('form-input', `input-${name}`, `form-component-${inputName || 'default'}`, { - 'input-error': hasErrors, - }); - - return ( -
- {beforeComponent ? beforeComponent : null} - {this.renderComponent()} - {hasErrors ? : null} - {this.showClear() ? this.renderClear() : null} - {this.showCharsRemaining() && ( -
- {this.state.charsRemaining} -
- )} - {afterComponent ? afterComponent : null} -
- ); - } } FormComponent.propTypes = { document: PropTypes.object, - name: PropTypes.string, + name: PropTypes.string.isRequired, label: PropTypes.string, value: PropTypes.any, placeholder: PropTypes.string, @@ -324,19 +200,21 @@ FormComponent.propTypes = { options: PropTypes.any, input: PropTypes.any, datatype: PropTypes.any, - path: PropTypes.string, + path: PropTypes.string.isRequired, disabled: PropTypes.bool, nestedSchema: PropTypes.object, - currentValues: PropTypes.object, - deletedValues: PropTypes.array, - updateCurrentValues: PropTypes.func, - errors: PropTypes.array, + currentValues: PropTypes.object.isRequired, + deletedValues: PropTypes.array.isRequired, + throwError: PropTypes.func.isRequired, + updateCurrentValues: PropTypes.func.isRequired, + errors: PropTypes.array.isRequired, addToDeletedValues: PropTypes.func, + clearFieldErrors: PropTypes.func.isRequired, + currentUser: PropTypes.object, }; FormComponent.contextTypes = { - intl: intlShape, - getDocument: PropTypes.func, + getDocument: PropTypes.func.isRequired, }; registerComponent('FormComponent', FormComponent); diff --git a/packages/vulcan-forms/lib/components/FormGroup.jsx b/packages/vulcan-forms/lib/components/FormGroup.jsx index 1087e4681..a6bbd1f35 100644 --- a/packages/vulcan-forms/lib/components/FormGroup.jsx +++ b/packages/vulcan-forms/lib/components/FormGroup.jsx @@ -67,8 +67,16 @@ FormGroup.propTypes = { name: PropTypes.string, label: PropTypes.string, order: PropTypes.number, - fields: PropTypes.array, - updateCurrentValues: PropTypes.func, + fields: PropTypes.array.isRequired, + errors: PropTypes.array.isRequired, + throwError: PropTypes.func.isRequired, + currentValues: PropTypes.object.isRequired, + updateCurrentValues: PropTypes.func.isRequired, + deletedValues: PropTypes.array.isRequired, + addToDeletedValues: PropTypes.func.isRequired, + clearFieldErrors: PropTypes.func.isRequired, + formType: PropTypes.string.isRequired, + currentUser: PropTypes.object, }; registerComponent('FormGroup', FormGroup); diff --git a/packages/vulcan-forms/lib/components/FormSubmit.jsx b/packages/vulcan-forms/lib/components/FormSubmit.jsx index ebd543fcc..f7be2d7e4 100644 --- a/packages/vulcan-forms/lib/components/FormSubmit.jsx +++ b/packages/vulcan-forms/lib/components/FormSubmit.jsx @@ -8,10 +8,15 @@ const FormSubmit = ({ submitLabel, cancelLabel, cancelCallback, + revertLabel, + revertCallback, document, deleteDocument, collectionName, classes, +}, { + isChanged, + clearForm, }) => (
@@ -29,7 +34,20 @@ const FormSubmit = ({ {cancelLabel ? cancelLabel : } ) : null} - + + {revertCallback ? ( + { + e.preventDefault(); + clearForm({ clearErrors: true, clearCurrentValues: true, clearDeletedValues: true }); + revertCallback(document); + }} + > + {revertLabel ? revertLabel : } + + ) : null} + {deleteDocument ? (

@@ -45,10 +63,18 @@ FormSubmit.propTypes = { submitLabel: PropTypes.string, cancelLabel: PropTypes.string, cancelCallback: PropTypes.func, + revertLabel: PropTypes.string, + revertCallback: PropTypes.func, document: PropTypes.object, deleteDocument: PropTypes.func, collectionName: PropTypes.string, classes: PropTypes.object, }; +FormSubmit.contextTypes = { + isChanged: PropTypes.func, + clearForm: PropTypes.func, +}; + + registerComponent('FormSubmit', FormSubmit); diff --git a/packages/vulcan-forms/lib/components/FormWrapper.jsx b/packages/vulcan-forms/lib/components/FormWrapper.jsx index eb85c6d43..54d0dfab3 100644 --- a/packages/vulcan-forms/lib/components/FormWrapper.jsx +++ b/packages/vulcan-forms/lib/components/FormWrapper.jsx @@ -27,6 +27,7 @@ component is also added to wait for withDocument's loading prop to be false) import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; +import { withRouter } from 'react-router' import { withApollo, compose } from 'react-apollo'; import { Components, @@ -265,7 +266,13 @@ FormWrapper.propTypes = { prefilledProps: PropTypes.object, layout: PropTypes.string, fields: PropTypes.arrayOf(PropTypes.string), + hideFields: PropTypes.arrayOf(PropTypes.string), showRemove: PropTypes.bool, + submitLabel: PropTypes.string, + cancelLabel: PropTypes.string, + revertLabel: PropTypes.string, + repeatErrors: PropTypes.bool, + warnUnsavedChanges: PropTypes.bool, // callbacks submitCallback: PropTypes.func, @@ -273,6 +280,7 @@ FormWrapper.propTypes = { removeSuccessCallback: PropTypes.func, errorCallback: PropTypes.func, cancelCallback: PropTypes.func, + revertCallback: PropTypes.func, currentUser: PropTypes.object, client: PropTypes.object, @@ -287,4 +295,4 @@ FormWrapper.contextTypes = { intl: intlShape } -registerComponent('SmartForm', FormWrapper, withCurrentUser, withApollo); +registerComponent('SmartForm', FormWrapper, withCurrentUser, withApollo, withRouter); diff --git a/packages/vulcan-i18n-en-us/lib/en_US.js b/packages/vulcan-i18n-en-us/lib/en_US.js index d3fb45d87..bec85119a 100644 --- a/packages/vulcan-i18n-en-us/lib/en_US.js +++ b/packages/vulcan-i18n-en-us/lib/en_US.js @@ -43,7 +43,9 @@ addStrings('en', { "forms.select_option": "-- select option --", "forms.delete": "Delete", "forms.delete_confirm": "Delete document?", - + "forms.revert": "Revert", + "forms.confirm_discard": "Discard changes?", + "users.profile": "Profile", "users.complete_profile": "Complete your Profile", "users.profile_completed": "Profile completed.", diff --git a/packages/vulcan-i18n-es-es/lib/es_ES.js b/packages/vulcan-i18n-es-es/lib/es_ES.js index cf596c83b..29390e905 100644 --- a/packages/vulcan-i18n-es-es/lib/es_ES.js +++ b/packages/vulcan-i18n-es-es/lib/es_ES.js @@ -43,6 +43,8 @@ addStrings('es', { "forms.select_option": "-- seleccionar opción --", "forms.delete": "Eliminar", "forms.delete_confirm": "¿Eliminar documento?", + "forms.revert": "Revert", // TODO: translate + "forms.confirm_discard": "Discard changes?", // TODO: translate "users.profile": "Perfil", "users.complete_profile": "Complete su perfil", diff --git a/packages/vulcan-i18n-fr-fr/lib/fr_FR.js b/packages/vulcan-i18n-fr-fr/lib/fr_FR.js index 9f93b1353..a2246bea5 100644 --- a/packages/vulcan-i18n-fr-fr/lib/fr_FR.js +++ b/packages/vulcan-i18n-fr-fr/lib/fr_FR.js @@ -45,6 +45,8 @@ addStrings('fr', { "forms.delete_confirm": "Supprimer le document?", "forms.next": "Suivant", "forms.previous": "Précédent", + "forms.revert": "Revert", // TODO: translate + "forms.confirm_discard": "Discard changes?", // TODO: translate "users.profile": "Profil", "users.complete_profile": "Complétez votre profil", diff --git a/packages/vulcan-lib/lib/modules/components.js b/packages/vulcan-lib/lib/modules/components.js index 1214363cd..2f8574781 100644 --- a/packages/vulcan-lib/lib/modules/components.js +++ b/packages/vulcan-lib/lib/modules/components.js @@ -1,4 +1,5 @@ import { compose } from 'react-apollo'; // note: at the moment, compose@react-apollo === compose@redux ; see https://github.com/apollostack/react-apollo/blob/master/src/index.ts#L4-L7 +import React from 'react'; export const Components = {}; // will be populated on startup (see vulcan:routing) export const ComponentsTable = {} // storage for infos about components @@ -112,3 +113,27 @@ export const populateComponentsApp = () => { export const copyHoCs = (sourceComponent, targetComponent) => { return compose(...sourceComponent.hocs)(targetComponent); } + +/** + * Returns an instance of the given component name of function + * @param {string|function} component A component or registered component name + * @param {Object} [props] Optional properties to pass to the component + */ +//eslint-disable-next-line react/display-name +export const instantiateComponent = (component, props) => { + if (!component) { + return null; + } else if (typeof component === 'string') { + const Component = getComponent(component); + return + } else if (typeof component === 'function' && component.prototype && component.prototype.isReactComponent) { + const Component = component; + return + } else if (typeof component === 'function') { + return component(props); + } else { + return component; + } +}; + + diff --git a/packages/vulcan-lib/lib/modules/utils.js b/packages/vulcan-lib/lib/modules/utils.js index 8862f84a7..671cf415a 100644 --- a/packages/vulcan-lib/lib/modules/utils.js +++ b/packages/vulcan-lib/lib/modules/utils.js @@ -112,6 +112,15 @@ Utils.scrollPageTo = function(selector) { $('body').scrollTop($(selector).offset().top); }; +Utils.scrollIntoView = function (selector) { + if (!document) return; + + const element = document.querySelector(selector); + if (element) { + element.scrollIntoView(); + } +}; + Utils.getDateRange = function(pageNumber) { var now = moment(new Date()); var dayToDisplay=now.subtract(pageNumber-1, 'days'); @@ -481,4 +490,4 @@ Utils.getRoutePath = routeName => { String.prototype.replaceAll = function(search, replacement) { var target = this; return target.replace(new RegExp(search, 'g'), replacement); -}; \ No newline at end of file +}; diff --git a/packages/vulcan-ui-bootstrap/lib/components/forms/FormComponentInner.jsx b/packages/vulcan-ui-bootstrap/lib/components/forms/FormComponentInner.jsx new file mode 100644 index 000000000..2d15e46c6 --- /dev/null +++ b/packages/vulcan-ui-bootstrap/lib/components/forms/FormComponentInner.jsx @@ -0,0 +1,208 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'meteor/vulcan:i18n'; +import { Components, registerComponent, instantiateComponent } from 'meteor/vulcan:core'; +import classNames from 'classnames'; + + +class FormComponentInner extends PureComponent { + + renderClear = () => { + if (['datetime', 'time', 'select', 'radiogroup'].includes(this.props.input)) { + return ( + + + + ); + } + }; + + getProperties = () => { + const { name, options, label, onChange, value } = this.props; + + // these properties are whitelisted so that they can be safely passed to the actual form input + // and avoid https://facebook.github.io/react/warnings/unknown-prop.html warnings + const inputProperties = { + name, + options, + label, + onChange, + value, + ...this.props.inputProperties, + }; + + return { + ...this.props, + inputProperties, + }; + }; + + renderExtraComponent = (extraComponent) => { + if (!extraComponent) return null; + + const properties = this.getProperties(); + + return instantiateComponent(extraComponent, properties); + }; + + + renderComponent = () => { + const { input, inputType, formType } = this.props; + const properties = this.getProperties(); + + // if input is a React component, use it + if (typeof input === 'function') { + const InputComponent = input; + return ; + } else { + // else pick a predefined component + + switch (inputType) { + case 'nested': + return ; + + case 'number': + return ; + + case 'url': + return ; + + case 'email': + return ; + + case 'textarea': + return ; + + case 'checkbox': + // formsy-react-components expects a boolean value for checkbox + // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/checkbox.js#L20 + properties.inputProperties.value = !!properties.inputProperties.value; + return ; + + case 'checkboxgroup': + // formsy-react-components expects an array value + // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/checkbox-group.js#L42 + if (!Array.isArray(properties.inputProperties.value)) { + properties.inputProperties.value = [properties.inputProperties.value]; + } + // in case of checkbox groups, check "checked" option to populate value if this is a "new + // document" form + const checkedValues = _.where(properties.options, { checked: true }) + .map(option => option.value); + if (checkedValues.length && !properties.inputProperties.value && formType === 'new') { + properties.inputProperties.value = checkedValues; + } + return ; + + case 'radiogroup': + // TODO: remove this? + // formsy-react-compnents RadioGroup expects an onChange callback + // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/radio-group.js#L33 + // properties.onChange = (name, value) => { + // this.context.updateCurrentValues({ [name]: value }); + // }; + return ; + + case 'select': + const noneOption = { + label: this.context.intl.formatMessage({ id: 'forms.select_option' }), + value: '', + disabled: true, + }; + properties.inputProperties.options = [noneOption, ...properties.inputProperties.options]; + + return ; + + case 'selectmultiple': + properties.inputProperties.multiple = true; + return ; + + case 'datetime': + return ; + + case 'date': + return ; + + case 'time': + return ; + + case 'text': + return ; + + default: + const CustomComponent = Components[input]; + return CustomComponent ? ( + + ) : ( + + ); + } + } + }; + + render () { + const { + inputClassName, + name, + input, + beforeComponent, + afterComponent, + errors, + showCharsRemaining, + charsRemaining, + } = this.props; + + const hasErrors = errors && errors.length; + + const inputName = typeof input === 'function' ? input.name : input; + const inputClass = classNames('form-input', inputClassName, `input-${name}`, + `form-component-${inputName || 'default'}`, { 'input-error': hasErrors, }); + + return ( +
+ {this.renderExtraComponent(beforeComponent)} + {this.renderComponent()} + {hasErrors ? : null} + {this.renderClear()} + {showCharsRemaining && +
+ {charsRemaining} +
+ } + {this.renderExtraComponent(afterComponent)} +
+ ); + } + +} + + +FormComponentInner.propTypes = { + inputClassName: PropTypes.string, + name: PropTypes.string.isRequired, + input: PropTypes.any, + beforeComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + afterComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + clearField: PropTypes.func.isRequired, + errors: PropTypes.array.isRequired, + help: PropTypes.node, + onChange: PropTypes.func.isRequired, + showCharsRemaining: PropTypes.bool.isRequired, + charsRemaining: PropTypes.number, + charsCount: PropTypes.number, + charsMax: PropTypes.number, +}; + +FormComponentInner.contextTypes = { + intl: intlShape, +}; + +FormComponentInner.displayName = 'FormComponentInner'; + + +registerComponent('FormComponentInner', FormComponentInner); diff --git a/packages/vulcan-ui-bootstrap/lib/modules/components.js b/packages/vulcan-ui-bootstrap/lib/modules/components.js index b6a1df38f..a4d647bd0 100644 --- a/packages/vulcan-ui-bootstrap/lib/modules/components.js +++ b/packages/vulcan-ui-bootstrap/lib/modules/components.js @@ -10,6 +10,7 @@ import '../components/forms/Textarea.jsx'; import '../components/forms/Time.jsx'; import '../components/forms/Date.jsx'; import '../components/forms/Url.jsx'; +import '../components/forms/FormComponentInner.jsx'; import '../components/forms/FormControl.jsx'; // note: only used by old accounts package, remove soon? import '../components/ui/Button.jsx';