Merge branch 'erikdakoda5' of https://github.com/ErikDakoda/Vulcan into ErikDakoda-erikdakoda5

# Conflicts:
#	packages/vulcan-forms/lib/components/FormComponent.jsx
This commit is contained in:
SachaG 2018-05-10 09:44:50 +09:00
commit d60b16ea0d
12 changed files with 506 additions and 234 deletions

View file

@ -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 React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { intlShape } from 'meteor/vulcan:i18n'; import { intlShape } from 'meteor/vulcan:i18n';
@ -34,8 +37,14 @@ import unset from 'lodash/unset';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
import update from 'lodash/update'; import update from 'lodash/update';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import find from 'lodash/find';
import isEqualWith from 'lodash/isEqualWith';
import { convertSchema, formProperties } from '../modules/schema_utils'; import { convertSchema, formProperties } from '../modules/schema_utils';
registerSetting('forms.warnUnsavedChanges', false,
'Warn user about unsaved changes before leaving route', true);
// unsetCompact // unsetCompact
const unsetCompact = (object, path) => { const unsetCompact = (object, path) => {
const parentPath = path.slice(0, path.lastIndexOf('.')); const parentPath = path.slice(0, path.lastIndexOf('.'));
@ -68,7 +77,7 @@ const computeStateFromProps = (nextProps) => {
*/ */
class Form extends Component { class Form extends Component {
constructor(props) { constructor (props) {
super(props); super(props);
this.state = { this.state = {
@ -119,7 +128,18 @@ class Form extends Component {
*/ */
getDocument = () => { 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; return document;
}; };
@ -223,9 +243,10 @@ class Form extends Component {
// remove all hidden fields // remove all hidden fields
if (excludeHiddenFields) { if (excludeHiddenFields) {
const document = this.getDocument();
relevantFields = _.reject(relevantFields, fieldName => { relevantFields = _.reject(relevantFields, fieldName => {
const hidden = schema[fieldName].hidden; 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 { return {
throwError: this.throwError, throwError: this.throwError,
clearForm: this.clearForm, 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, addToDeletedValues: this.addToDeletedValues,
updateCurrentValues: this.updateCurrentValues, updateCurrentValues: this.updateCurrentValues,
getDocument: this.getDocument, 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). Manually update the current values of one or more fields(i.e. on change or blur).
*/ */
updateCurrentValues = newValues => { updateCurrentValues = newValues => {
// keep the previous ones and extend (with possible replacement) with new ones // keep the previous ones and extend (with possible replacement) with new ones
@ -450,22 +477,93 @@ class Form extends Component {
return newState; 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 Clear and reset the form
By default, clear errors and keep current values and deleted values 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 => ({ this.setState(prevState => ({
errors: clearErrors ? [] : prevState.errors, errors: clearErrors ? [] : prevState.errors,
currentValues: clearCurrentValues ? {} : prevState.currentValues, currentValues: clearCurrentValues ? {} : prevState.currentValues,
deletedValues: clearDeletedValues ? [] : prevState.deletedValues, deletedValues: clearDeletedValues ? [] : prevState.deletedValues,
initialDocument: document ? document : prevState.initialDocument,
disabled: false, disabled: false,
})); }));
}; };
/* /*
Key down handler Key down handler
*/ */
@ -489,15 +587,11 @@ class Form extends Component {
// for new mutation, run refetch function if it exists // for new mutation, run refetch function if it exists
if (mutationType === 'new' && this.props.refetch) this.props.refetch(); 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') { if (typeof this.refs.form !== 'undefined') {
let clearCurrentValues = false; this.refs.form.reset();
// reset form if this is a new document form this.clearForm({ clearErrors: true, clearCurrentValues: true, clearDeletedValues: true, document });
if (this.getFormType() === 'new') {
this.refs.form.reset();
clearCurrentValues = true;
}
this.clearForm({ clearErrors: true, clearCurrentValues, clearDeletedValues: true });
} }
// run document through mutation success callbacks // run document through mutation success callbacks
@ -523,9 +617,11 @@ class Form extends Component {
// add error to state // add error to state
this.throwError(error); this.throwError(error);
} }
// run error callback if it exists // run error callback if it exists
if (this.props.errorCallback) this.props.errorCallback(document, error); if (this.props.errorCallback) this.props.errorCallback(document, error);
Utils.scrollIntoView('.flash-message');
}; };
/* /*
@ -632,14 +728,14 @@ class Form extends Component {
// ----------------------------- Render -------------------------------- // // ----------------------------- Render -------------------------------- //
// --------------------------------------------------------------------- // // --------------------------------------------------------------------- //
render() { render () {
const fieldGroups = this.getFieldGroups(); const fieldGroups = this.getFieldGroups();
const collectionName = this.getCollection()._name; const collectionName = this.getCollection()._name;
return ( return (
<div className={'document-' + this.getFormType()}> <div className={'document-' + this.getFormType()}>
<Formsy.Form onSubmit={this.submitForm} onKeyDown={this.formKeyDown} disabled={this.state.disabled} ref="form"> <Formsy.Form onSubmit={this.submitForm} onKeyDown={this.formKeyDown} disabled={this.state.disabled} ref="form">
<Components.FormErrors errors={this.state.errors} /> <Components.FormErrors errors={this.state.errors}/>
{fieldGroups.map(group => ( {fieldGroups.map(group => (
<Components.FormGroup <Components.FormGroup
@ -662,7 +758,9 @@ class Form extends Component {
<Components.FormSubmit <Components.FormSubmit
submitLabel={this.props.submitLabel} submitLabel={this.props.submitLabel}
cancelLabel={this.props.cancelLabel} cancelLabel={this.props.cancelLabel}
revertLabel={this.props.revertLabel}
cancelCallback={this.props.cancelCallback} cancelCallback={this.props.cancelCallback}
revertCallback={this.props.revertCallback}
document={this.getDocument()} document={this.getDocument()}
deleteDocument={(this.getFormType() === 'edit' && this.props.showRemove && this.deleteDocument) || null} deleteDocument={(this.getFormType() === 'edit' && this.props.showRemove && this.deleteDocument) || null}
collectionName={collectionName} collectionName={collectionName}
@ -700,7 +798,9 @@ Form.propTypes = {
showRemove: PropTypes.bool, showRemove: PropTypes.bool,
submitLabel: PropTypes.string, submitLabel: PropTypes.string,
cancelLabel: PropTypes.string, cancelLabel: PropTypes.string,
revertLabel: PropTypes.string,
repeatErrors: PropTypes.bool, repeatErrors: PropTypes.bool,
warnUnsavedChanges: PropTypes.bool,
// callbacks // callbacks
submitCallback: PropTypes.func, submitCallback: PropTypes.func,
@ -708,6 +808,7 @@ Form.propTypes = {
removeSuccessCallback: PropTypes.func, removeSuccessCallback: PropTypes.func,
errorCallback: PropTypes.func, errorCallback: PropTypes.func,
cancelCallback: PropTypes.func, cancelCallback: PropTypes.func,
revertCallback: PropTypes.func,
currentUser: PropTypes.object, currentUser: PropTypes.object,
client: PropTypes.object, client: PropTypes.object,
@ -734,6 +835,8 @@ Form.childContextTypes = {
setFormState: PropTypes.func, setFormState: PropTypes.func,
throwError: PropTypes.func, throwError: PropTypes.func,
clearForm: PropTypes.func, clearForm: PropTypes.func,
refetchForm: PropTypes.func,
isChanged: PropTypes.func,
initialDocument: PropTypes.object, initialDocument: PropTypes.object,
getDocument: PropTypes.func, getDocument: PropTypes.func,
submitForm: PropTypes.func, submitForm: PropTypes.func,

View file

@ -1,46 +1,60 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { intlShape } from 'meteor/vulcan:i18n';
import classNames from 'classnames';
import { Components } from 'meteor/vulcan:core'; import { Components } from 'meteor/vulcan:core';
import { registerComponent } from 'meteor/vulcan:core'; import { registerComponent } from 'meteor/vulcan:core';
import debounce from 'lodash.debounce';
import get from 'lodash/get'; import get from 'lodash/get';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import find from 'lodash/find'; import find from 'lodash/find';
import isObjectLike from 'lodash/isObjectLike'; import isObjectLike from 'lodash/isObjectLike';
import isEqual from 'lodash/isEqual';
import { isEmptyValue } from '../modules/utils.js'; import { isEmptyValue } from '../modules/utils.js';
class FormComponent extends Component { class FormComponent extends Component {
constructor(props) {
constructor (props) {
super(props); super(props);
const value = this.getValue(props); this.state = {};
}
if (this.showCharsRemaining(props)) { componentWillMount () {
const characterCount = value ? value.length : 0; if (this.showCharsRemaining()) {
this.state = { const value = this.getValue();
charsRemaining: props.max - characterCount, this.updateCharacterCount(value);
};
} }
} }
// shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate (nextProps, nextState) {
// const { currentValues, deletedValues, errors } = nextProps; // allow custom controls to determine if they should update
// const { path } = this.props; if (!['nested', 'number', 'url', 'email', 'textarea', 'checkbox',
// const hasChanged = currentValues[path] && currentValues[path] !== this.props.currentValues[path]; 'checkboxgroup', 'radiogroup', 'select', 'selectmultiple', 'datetime',
// const hasError = !!errors[path]; 'date', 'time', 'text'].includes(this.getType(nextProps))) {
// const hasBeenDeleted = deletedValues.includes(path) && !this.props.deletedValues.includes(path) return true;
// return hasChanged || hasError || hasBeenDeleted; }
// }
const { currentValues, deletedValues, errors } = nextProps;
const { path } = this.props;
const valueChanged = currentValues[path] !== this.props.currentValues[path];
const errorChanged = !isEqual(this.getErrors(errors), this.getErrors());
const deleteChanged = deletedValues.includes(path) !== this.props.deletedValues.includes(path);
const charsChanged = nextState.charsRemaining !== this.state.charsRemaining;
return valueChanged || errorChanged || deleteChanged || charsChanged;
}
/*
Function passed to form controls (always controlled) to update their value
*/
handleChange = (name, value) => { handleChange = (name, value) => {
// if value is an empty string, delete the field // if value is an empty string, delete the field
if (value === '') { if (value === '') {
value = null; value = null;
} }
// if this is a number field, convert value before sending it up to Form // 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); value = Number(value);
} }
this.props.updateCurrentValues({ [this.props.path]: value }); this.props.updateCurrentValues({ [this.props.path]: value });
@ -52,17 +66,15 @@ class FormComponent extends Component {
}; };
/* /*
Note: not currently used because when function is debounced Updates the state of charsCount and charsRemaining as the users types
some changes might not register if the user submits form too soon
*/ */
handleChangeDebounced = debounce(this.handleChange, 500, { leading: true });
updateCharacterCount = value => { updateCharacterCount = value => {
const characterCount = value ? value.length : 0; const characterCount = value ? value.length : 0;
this.setState({ this.setState({
charsRemaining: this.props.max - characterCount, charsRemaining: this.props.max - characterCount,
charsCount: characterCount,
}); });
}; };
@ -90,7 +102,15 @@ class FormComponent extends Component {
value = merge({}, documentValue, currentValue); value = merge({}, documentValue, currentValue);
} else { } else {
// note: value has to default to '' to make component controlled // 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 // replace empty value, which has not been prefilled, by the default value from the schema
if (isEmptyValue(value)) { if (isEmptyValue(value)) {
@ -116,8 +136,9 @@ class FormComponent extends Component {
Get errors from Form state through context Get errors from Form state through context
*/ */
getErrors = () => { getErrors = (errors) => {
const fieldErrors = this.props.errors.filter(error => error.path === this.props.path); errors = errors || this.props.errors;
const fieldErrors = errors.filter(error => error.path === this.props.path);
return fieldErrors; return fieldErrors;
}; };
@ -131,194 +152,49 @@ class FormComponent extends Component {
const p = props || this.props; const p = props || this.props;
const fieldType = p.datatype && p.datatype[0].type; const fieldType = p.datatype && p.datatype[0].type;
const autoType = 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; return p.input || autoType;
}; };
renderComponent() { /*
const {
input, Function passed to form controls to clear their contents (set their value to null)
beforeComponent,
afterComponent, */
options, clearField = event => {
name, event.preventDefault();
label, event.stopPropagation();
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 <InputComponent {...properties} />;
} else {
// else pick a predefined component
switch (this.getType()) {
case 'nested':
return <Components.FormNested {...properties} />;
case 'number':
return <Components.FormComponentNumber {...properties} />;
case 'url':
return <Components.FormComponentUrl {...properties} />;
case 'email':
return <Components.FormComponentEmail {...properties} />;
case 'textarea':
return <Components.FormComponentTextarea {...properties} />;
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 <Components.FormComponentCheckbox {...properties} />;
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 <Components.FormComponentCheckboxGroup {...properties} />;
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 <Components.FormComponentRadioGroup {...properties} />;
case 'select':
const noneOption = {
label: this.context.intl.formatMessage({ id: 'forms.select_option' }),
value: '',
disabled: true,
};
properties.inputProperties.options = [noneOption, ...properties.inputProperties.options];
return <Components.FormComponentSelect {...properties} />;
case 'selectmultiple':
properties.inputProperties.multiple = true;
return <Components.FormComponentSelect {...properties} />;
case 'datetime':
return <Components.FormComponentDateTime {...properties} />;
case 'date':
return <Components.FormComponentDate {...properties} />;
case 'time':
return <Components.FormComponentTime {...properties} />;
case 'text':
return <Components.FormComponentDefault {...properties} />;
default:
const CustomComponent = Components[input];
return CustomComponent ? (
<CustomComponent {...properties} />
) : (
<Components.FormComponentDefault {...properties} />
);
}
}
}
showClear = () => {
return ['datetime', 'time', 'select', 'radiogroup'].includes(this.props.input);
};
clearField = e => {
e.preventDefault();
this.props.updateCurrentValues({ [this.props.path]: null }); this.props.updateCurrentValues({ [this.props.path]: null });
if (this.showCharsRemaining()) {
this.updateCharacterCount(null);
}
}; };
renderClear() { render () {
return ( return (
<a <Components.FormComponentInner
href="javascript:void(0)" {...this.props}
className="form-component-clear" {...this.state}
title={this.context.intl.formatMessage({ id: 'forms.clear_field' })} inputType={this.getType()}
onClick={this.clearField} value={this.getValue()}
> errors={this.getErrors()}
<span></span> document={this.context.getDocument()}
</a> showCharsRemaining={!!this.showCharsRemaining()}
onChange={this.handleChange}
clearField={this.clearField}
/>
); );
} }
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 (
<div className={inputClass}>
{beforeComponent ? beforeComponent : null}
{this.renderComponent()}
{hasErrors ? <Components.FieldErrors errors={this.getErrors()} /> : null}
{this.showClear() ? this.renderClear() : null}
{this.showCharsRemaining() && (
<div className={classNames('form-control-limit', { danger: this.state.charsRemaining < 10 })}>
{this.state.charsRemaining}
</div>
)}
{afterComponent ? afterComponent : null}
</div>
);
}
} }
FormComponent.propTypes = { FormComponent.propTypes = {
document: PropTypes.object, document: PropTypes.object,
name: PropTypes.string, name: PropTypes.string.isRequired,
label: PropTypes.string, label: PropTypes.string,
value: PropTypes.any, value: PropTypes.any,
placeholder: PropTypes.string, placeholder: PropTypes.string,
@ -326,19 +202,21 @@ FormComponent.propTypes = {
options: PropTypes.any, options: PropTypes.any,
input: PropTypes.any, input: PropTypes.any,
datatype: PropTypes.any, datatype: PropTypes.any,
path: PropTypes.string, path: PropTypes.string.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
nestedSchema: PropTypes.object, nestedSchema: PropTypes.object,
currentValues: PropTypes.object, currentValues: PropTypes.object.isRequired,
deletedValues: PropTypes.array, deletedValues: PropTypes.array.isRequired,
updateCurrentValues: PropTypes.func, throwError: PropTypes.func.isRequired,
errors: PropTypes.array, updateCurrentValues: PropTypes.func.isRequired,
errors: PropTypes.array.isRequired,
addToDeletedValues: PropTypes.func, addToDeletedValues: PropTypes.func,
clearFieldErrors: PropTypes.func.isRequired,
currentUser: PropTypes.object,
}; };
FormComponent.contextTypes = { FormComponent.contextTypes = {
intl: intlShape, getDocument: PropTypes.func.isRequired,
getDocument: PropTypes.func,
}; };
registerComponent('FormComponent', FormComponent); registerComponent('FormComponent', FormComponent);

View file

@ -67,8 +67,16 @@ FormGroup.propTypes = {
name: PropTypes.string, name: PropTypes.string,
label: PropTypes.string, label: PropTypes.string,
order: PropTypes.number, order: PropTypes.number,
fields: PropTypes.array, fields: PropTypes.array.isRequired,
updateCurrentValues: PropTypes.func, 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); registerComponent('FormGroup', FormGroup);

View file

@ -8,10 +8,15 @@ const FormSubmit = ({
submitLabel, submitLabel,
cancelLabel, cancelLabel,
cancelCallback, cancelCallback,
revertLabel,
revertCallback,
document, document,
deleteDocument, deleteDocument,
collectionName, collectionName,
classes, classes,
}, {
isChanged,
clearForm,
}) => ( }) => (
<div className="form-submit"> <div className="form-submit">
<Components.Button type="submit" variant="primary"> <Components.Button type="submit" variant="primary">
@ -29,7 +34,20 @@ const FormSubmit = ({
{cancelLabel ? cancelLabel : <FormattedMessage id="forms.cancel" />} {cancelLabel ? cancelLabel : <FormattedMessage id="forms.cancel" />}
</a> </a>
) : null} ) : null}
{revertCallback ? (
<a
className="form-cancel"
onClick={e => {
e.preventDefault();
clearForm({ clearErrors: true, clearCurrentValues: true, clearDeletedValues: true });
revertCallback(document);
}}
>
{revertLabel ? revertLabel : <FormattedMessage id="forms.revert"/>}
</a>
) : null}
{deleteDocument ? ( {deleteDocument ? (
<div> <div>
<hr /> <hr />
@ -45,10 +63,18 @@ FormSubmit.propTypes = {
submitLabel: PropTypes.string, submitLabel: PropTypes.string,
cancelLabel: PropTypes.string, cancelLabel: PropTypes.string,
cancelCallback: PropTypes.func, cancelCallback: PropTypes.func,
revertLabel: PropTypes.string,
revertCallback: PropTypes.func,
document: PropTypes.object, document: PropTypes.object,
deleteDocument: PropTypes.func, deleteDocument: PropTypes.func,
collectionName: PropTypes.string, collectionName: PropTypes.string,
classes: PropTypes.object, classes: PropTypes.object,
}; };
FormSubmit.contextTypes = {
isChanged: PropTypes.func,
clearForm: PropTypes.func,
};
registerComponent('FormSubmit', FormSubmit); registerComponent('FormSubmit', FormSubmit);

View file

@ -27,6 +27,7 @@ component is also added to wait for withDocument's loading prop to be false)
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { intlShape } from 'meteor/vulcan:i18n'; import { intlShape } from 'meteor/vulcan:i18n';
import { withRouter } from 'react-router'
import { withApollo, compose } from 'react-apollo'; import { withApollo, compose } from 'react-apollo';
import { import {
Components, Components,
@ -265,7 +266,13 @@ FormWrapper.propTypes = {
prefilledProps: PropTypes.object, prefilledProps: PropTypes.object,
layout: PropTypes.string, layout: PropTypes.string,
fields: PropTypes.arrayOf(PropTypes.string), fields: PropTypes.arrayOf(PropTypes.string),
hideFields: PropTypes.arrayOf(PropTypes.string),
showRemove: PropTypes.bool, showRemove: PropTypes.bool,
submitLabel: PropTypes.string,
cancelLabel: PropTypes.string,
revertLabel: PropTypes.string,
repeatErrors: PropTypes.bool,
warnUnsavedChanges: PropTypes.bool,
// callbacks // callbacks
submitCallback: PropTypes.func, submitCallback: PropTypes.func,
@ -273,6 +280,7 @@ FormWrapper.propTypes = {
removeSuccessCallback: PropTypes.func, removeSuccessCallback: PropTypes.func,
errorCallback: PropTypes.func, errorCallback: PropTypes.func,
cancelCallback: PropTypes.func, cancelCallback: PropTypes.func,
revertCallback: PropTypes.func,
currentUser: PropTypes.object, currentUser: PropTypes.object,
client: PropTypes.object, client: PropTypes.object,
@ -287,4 +295,4 @@ FormWrapper.contextTypes = {
intl: intlShape intl: intlShape
} }
registerComponent('SmartForm', FormWrapper, withCurrentUser, withApollo); registerComponent('SmartForm', FormWrapper, withCurrentUser, withApollo, withRouter);

View file

@ -43,7 +43,9 @@ addStrings('en', {
"forms.select_option": "-- select option --", "forms.select_option": "-- select option --",
"forms.delete": "Delete", "forms.delete": "Delete",
"forms.delete_confirm": "Delete document?", "forms.delete_confirm": "Delete document?",
"forms.revert": "Revert",
"forms.confirm_discard": "Discard changes?",
"users.profile": "Profile", "users.profile": "Profile",
"users.complete_profile": "Complete your Profile", "users.complete_profile": "Complete your Profile",
"users.profile_completed": "Profile completed.", "users.profile_completed": "Profile completed.",

View file

@ -43,6 +43,8 @@ addStrings('es', {
"forms.select_option": "-- seleccionar opción --", "forms.select_option": "-- seleccionar opción --",
"forms.delete": "Eliminar", "forms.delete": "Eliminar",
"forms.delete_confirm": "¿Eliminar documento?", "forms.delete_confirm": "¿Eliminar documento?",
"forms.revert": "Revert", // TODO: translate
"forms.confirm_discard": "Discard changes?", // TODO: translate
"users.profile": "Perfil", "users.profile": "Perfil",
"users.complete_profile": "Complete su perfil", "users.complete_profile": "Complete su perfil",

View file

@ -45,6 +45,8 @@ addStrings('fr', {
"forms.delete_confirm": "Supprimer le document?", "forms.delete_confirm": "Supprimer le document?",
"forms.next": "Suivant", "forms.next": "Suivant",
"forms.previous": "Précédent", "forms.previous": "Précédent",
"forms.revert": "Revert", // TODO: translate
"forms.confirm_discard": "Discard changes?", // TODO: translate
"users.profile": "Profil", "users.profile": "Profil",
"users.complete_profile": "Complétez votre profil", "users.complete_profile": "Complétez votre profil",

View file

@ -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 { 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 Components = {}; // will be populated on startup (see vulcan:routing)
export const ComponentsTable = {} // storage for infos about components export const ComponentsTable = {} // storage for infos about components
@ -112,3 +113,27 @@ export const populateComponentsApp = () => {
export const copyHoCs = (sourceComponent, targetComponent) => { export const copyHoCs = (sourceComponent, targetComponent) => {
return compose(...sourceComponent.hocs)(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 <Component {...props}/>
} else if (typeof component === 'function' && component.prototype && component.prototype.isReactComponent) {
const Component = component;
return <Component {...props}/>
} else if (typeof component === 'function') {
return component(props);
} else {
return component;
}
};

View file

@ -112,6 +112,15 @@ Utils.scrollPageTo = function(selector) {
$('body').scrollTop($(selector).offset().top); $('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) { Utils.getDateRange = function(pageNumber) {
var now = moment(new Date()); var now = moment(new Date());
var dayToDisplay=now.subtract(pageNumber-1, 'days'); var dayToDisplay=now.subtract(pageNumber-1, 'days');
@ -481,4 +490,4 @@ Utils.getRoutePath = routeName => {
String.prototype.replaceAll = function(search, replacement) { String.prototype.replaceAll = function(search, replacement) {
var target = this; var target = this;
return target.replace(new RegExp(search, 'g'), replacement); return target.replace(new RegExp(search, 'g'), replacement);
}; };

View file

@ -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 (
<a
href="javascript:void(0)"
className="form-component-clear"
title={this.context.intl.formatMessage({ id: 'forms.clear_field' })}
onClick={this.props.clearField}
>
<span></span>
</a>
);
}
};
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 <InputComponent {...properties} />;
} else {
// else pick a predefined component
switch (inputType) {
case 'nested':
return <Components.FormNested {...properties} />;
case 'number':
return <Components.FormComponentNumber {...properties} />;
case 'url':
return <Components.FormComponentUrl {...properties} />;
case 'email':
return <Components.FormComponentEmail {...properties} />;
case 'textarea':
return <Components.FormComponentTextarea {...properties} />;
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 <Components.FormComponentCheckbox {...properties} />;
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 <Components.FormComponentCheckboxGroup {...properties} />;
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 <Components.FormComponentRadioGroup {...properties} />;
case 'select':
const noneOption = {
label: this.context.intl.formatMessage({ id: 'forms.select_option' }),
value: '',
disabled: true,
};
properties.inputProperties.options = [noneOption, ...properties.inputProperties.options];
return <Components.FormComponentSelect {...properties} />;
case 'selectmultiple':
properties.inputProperties.multiple = true;
return <Components.FormComponentSelect {...properties} />;
case 'datetime':
return <Components.FormComponentDateTime {...properties} />;
case 'date':
return <Components.FormComponentDate {...properties} />;
case 'time':
return <Components.FormComponentTime {...properties} />;
case 'text':
return <Components.FormComponentDefault {...properties} />;
default:
const CustomComponent = Components[input];
return CustomComponent ? (
<CustomComponent {...properties} />
) : (
<Components.FormComponentDefault {...properties} />
);
}
}
};
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 (
<div className={inputClass}>
{this.renderExtraComponent(beforeComponent)}
{this.renderComponent()}
{hasErrors ? <Components.FieldErrors errors={errors}/> : null}
{this.renderClear()}
{showCharsRemaining &&
<div className={classNames('form-control-limit', { danger: charsRemaining < 10 })}>
{charsRemaining}
</div>
}
{this.renderExtraComponent(afterComponent)}
</div>
);
}
}
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);

View file

@ -10,6 +10,7 @@ import '../components/forms/Textarea.jsx';
import '../components/forms/Time.jsx'; import '../components/forms/Time.jsx';
import '../components/forms/Date.jsx'; import '../components/forms/Date.jsx';
import '../components/forms/Url.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/forms/FormControl.jsx'; // note: only used by old accounts package, remove soon?
import '../components/ui/Button.jsx'; import '../components/ui/Button.jsx';