/* global WP_DEFINE_DEVELOPMENT, WP_DEFINE_IS_NODE */
import debugLib from 'debug';
import createAction from 'redux-actions/lib/createAction';
import { startSubmit as reduxFormStartSubmit, stopSubmit as reduxFormStopSubmit } from 'redux-form';
import { wizardStepSubmit } from './wizardFormActions';
import isSubmissionError from '../utils/isSubmissionError';
import getRegisteredFields from '../utils/getRegisteredFields';
import isFieldRegistered from '../utils/isFieldRegistered';
import { composeCompositeInputs } from './compositeInputActions';
import { COMPOSITE_FORMSECTION_NAME_PREFIX } from '../data/constants';
/**
* All functions in this module are action creators and the return value
* should be passed to the redux store dispatch() function
*
* @module enhanced-redux-form/actions/enhancedFormActions
* @category forms
*/
const debug = debugLib('SlimmingWorld:enhancedFormActions');
export const VALIDATE_FORM = 'enhancedFormActions/VALIDATE_FORM';
export const REGISTER_FORM = 'enhancedFormActions/REGISTER_FORM';
export const DESTROY_ENHANCED_FORM = 'enhancedFormActions/DESTROY_ENHANCED_FORM';
export const FORM_SHOULD_VALIDATE = 'enhancedFormActions/FORM_SHOULD_VALIDATE';
export const CLEAR_VALIDATION = 'enhancedFormActions/CLEAR_VALIDATION';
export const HANDLE_SUBMIT_ERRORS = 'enhancedFormActions/HANDLE_SUBMIT_ERRORS';
export const SUBMIT = 'enhancedFormActions/SUBMIT';
/**
* @typedef ValidationRule
* @property {function} rule A function that returns true or false for valid or invalid. May
* also return a promise that resolves with true or false. It will receive the following arguments:
* - value The current value of the field
* - values An object containing all the values in the form
* - name The name of the field that is being validated
* - dispatch The redux store dispatch() method
* @property {string} message A message that should be set as error message when
* this field is invalid.
*/
/**
* @typedef ValidationConfig
* @property {string|Array<string>} [validateOn] A string or array of strings that specifies
* on which event the validation should trigger. See ValidateOn.js for possible values.
* @property {Array<ValidationRule>} validators An array of validation rules to test this
* field for.
* @property {boolean} [onlyValidateIfMounted=true] If false, will also validate this field
* if it's not mounted in the form. An error in this validation will also block the form
* submission
*/
/**
* Validate the given form and call submitHandler when the validation succeeds
* @function submitForm
* @param form The name of the form to submit
* @param validation The validation configuration object as passed to the enhancedReduxForm
* HOC. See validateForm() for more info
* @param [formProps] The props that were passed to the form component that is being submitted.
* Will be passed on to the onSubmit handler.
* @param submitHandler The function to call when validation succeeds.
* @param {function} [transformApiFieldNames] A function that maps the field names in the validation
* errors returned by the API to field names in the actual form.
* @param {function} [submitSuccessHandler] A callback that will be called when the submitHandler
* function executes without error
* @param {function} [submitFailHandler] A callback that will be called when there is a validation
* error or an error in execution of submitHandler
* @param {string} [generalErrorMessage] When the API call comes back with an error that doesn't
* have the default error response shape, this message will be shown instead.
* @param wizardRoutingAdapter
* @param isWizardStep {Boolean} If the current form is not a wizard step it should not go to the next step when a wizard has been found in store
* @returns {function} Function that will be handled by redux-thunk
*/
export const submitForm = ({
form,
validation = {},
formProps = {},
submitHandler = () => {},
transformApiFieldNames,
submitSuccessHandler,
submitFailHandler,
generalErrorMessage,
wizardRoutingAdapter,
isWizardStep = true,
}) => (dispatch, getState) => {
dispatch(composeCompositeInputs(form));
return dispatch({
type: SUBMIT,
payload: dispatch(validateForm(validation, form)).then(() => {
const state = getState();
const formState = state.form[form];
const registeredFields = getRegisteredFields(state, form);
const { errors } = state.enhancedForm.validation[form];
const noErrors = !Object.keys(errors).some(
fieldName =>
isFieldRegistered(registeredFields, fieldName) ||
(validation[fieldName] && validation[fieldName].onlyValidateIfMounted === false),
);
if (noErrors) {
dispatch(reduxFormStartSubmit(form));
const values = {};
if (formState && formState.values) {
const prefix = COMPOSITE_FORMSECTION_NAME_PREFIX;
Object.keys(formState.values)
.filter(name => !name.startsWith(prefix))
.forEach(name => (values[name] = formState.values[name]));
}
return Promise.resolve()
.then(() => submitHandler(values, dispatch, formProps))
.then(result => {
dispatch(reduxFormStopSubmit(form, {}));
const wizard = findWizardWithForm(state, form);
if (wizard && isWizardStep) {
return dispatch(
wizardStepSubmit(wizard.wizardName, wizard.stepIndex, wizardRoutingAdapter, values),
);
}
submitSuccessHandler && submitSuccessHandler(result, dispatch, formProps);
return result;
})
.catch(error => {
// add a dummy error to indicate to redux-form that this was a failed submission
dispatch(reduxFormStopSubmit(form, { _error: 'failed' }));
submitFailHandler && submitFailHandler(errors, dispatch, null, formProps);
if (isSubmissionError(error)) {
return dispatch(
handleSubmitErrors(form, error.response.parsed.error, transformApiFieldNames),
);
}
if (generalErrorMessage) {
dispatch(
handleSubmitErrors(form, {
message: generalErrorMessage,
}),
);
}
throw error;
});
}
return null;
}),
meta: {
form,
},
});
};
/**
* __Note: this action is used internally by enhanced-redux-form. You will probably not
* need this for general usage__
*
* Handles submission errors that are thrown during form submission.
* On development, verifies that the fields on the given errors array exist on the
* form.
*
* @function handleSubmitErrors
* @param {string} form The form that is being submitted
* @param {Object} errorObj An object containing an error response from the backend API
* @param {string} [errorObj.message] A general error message
* @param {Array} [errorObj.fields] An array of submission errors per field
* @param {string} errorObj.fields[].field The name of the field that the error occurred on
* @param {string} errorObj.fields[].message The error message for the field
* @param {function} [transformApiFieldNames] A function that maps the field names in the validation
* errors returned by the API to field names in the actual form.
* @returns {function} Function that will be handled by redux-thunk
*/
export const handleSubmitErrors = (form, errorObj, transformApiFieldNames = name => name) => (
dispatch,
getState,
) => {
const state = getState();
const {
form: {
[form]: {},
},
} = state;
const fieldErrors = errorObj.fields || [];
const mappedFieldErrors = fieldErrors.map(error =>
error.message
? error
: {
...error,
message: { locale: error.code },
},
);
const fieldErrorsObj = mappedFieldErrors
.filter(error => error.field && error.message)
.reduce((result, error) => {
const localFieldName = transformApiFieldNames(error.field);
// eslint-disable-next-line no-param-reassign
result[localFieldName] = { message: error.message, code: error.code };
return result;
}, {});
if (WP_DEFINE_DEVELOPMENT) {
// on development, verify that all fields on the error object actually exist
const registeredFields = getRegisteredFields(state, form);
fieldErrors.forEach(error => {
const localFieldName = transformApiFieldNames(error.field);
if (error.field && !isFieldRegistered(registeredFields, localFieldName)) {
debug(
`Submission errors contain a field with name '${error.field}' (transformed to '${localFieldName}') that does not exist in the form "${form}"`,
);
}
});
}
dispatch({
type: HANDLE_SUBMIT_ERRORS,
payload: {
errors: fieldErrorsObj,
generalError: errorObj.message ? errorObj.message : null,
generalErrorCode: errorObj.code ? errorObj.code : null,
},
meta: { form },
});
};
/**
* Runs validation on the given form
* @function validateForm
* @param {Object<string, ValidationConfig>} validation The validation configuration object.
* The keys of this object correspond to names of fields in the form, and the values are the
* corresponding validation.
* @param {string} form The name of the form to validate
* @param {Array<string>} [fields] An array of field names to validate. If omitted, runs
* validation on all fields
* @returns {function} Function that will be handled by redux-thunk
*/
export const validateForm = (validation = {}, form, fields) => (dispatch, getState) => {
const state = getState();
const formState = state.form[form];
const validationState = state.enhancedForm.validation[form];
const formValues = (formState && formState.values) || {};
const registeredFields = getRegisteredFields(state, form);
const validationErrors = {};
const fieldsToValidate = fields && fields.length ? fields : Object.keys(validation);
const validatedFields = fieldsToValidate.filter(
fieldName =>
!(validationState && validationState.hasCompositeError.includes(fieldName)) &&
(isFieldRegistered(registeredFields, fieldName) ||
(validation[fieldName] && validation[fieldName].onlyValidateIfMounted === false)),
);
// We attach the start time of validation to the action. This is to prevent race conditions
// when overwriting new validation results with older validation calls
const validationStartTime = new Date().getTime();
const validationPromises = validatedFields.map(fieldName => {
if (!validation[fieldName]) {
debug(`no validation defined for fields "${fieldName}" on "${form}"`);
return Promise.resolve();
}
const fieldValidators = validation[fieldName].validators;
// we use reduce here because the rules need to execute in sequence
return fieldValidators.reduce((prevValidation, fieldValidator) => {
if (WP_DEFINE_DEVELOPMENT) {
if (
typeof fieldValidator.message === 'undefined' &&
typeof fieldValidator.code === 'undefined'
) {
throw new ReferenceError(
`A validation rule should have either an error message or an error code. Check the "${fieldName}" validation of the "${form}" form`,
);
}
}
// callback that adds an error message to the error object if a result is falsy
const processResult = result => {
if (!result) {
validationErrors[fieldName] = {
message: fieldValidator.message,
code: fieldValidator.code,
};
}
return result;
};
if (prevValidation === null) {
// there is no previous validation rule. run the first rule
return Promise.resolve(
fieldValidator.rule(
extractFormValue(formValues, fieldName),
formValues,
fieldName,
dispatch,
),
).then(processResult);
}
// there is a previous validation rule. wait for it to complete
return prevValidation.then(isValid => {
// if the previous validation resulted falsy, skip future rules
if (!isValid) {
return false;
}
return Promise.resolve(
fieldValidator.rule(
extractFormValue(formValues, fieldName),
formValues,
fieldName,
dispatch,
),
).then(processResult);
});
}, null);
});
return dispatch({
type: VALIDATE_FORM,
payload: Promise.all(validationPromises).then(() => ({
errors: validationErrors,
})),
meta: {
// Data on meta rather than payload, to make it available when promise is still pending
form,
fields: [...validatedFields],
startTime: validationStartTime,
},
});
};
/**
* __Note: this action is used internally by enhanced-redux-form. You will probably not
* need this for general usage__
*
* Registers mounting of a form with validation. This registration is used by the
* enhancedReduxFormMiddleware to listen for events configured in the validation config object
* and trigger validation if neccessary
* @function registerForm
* @param {string} form The name of the form
* @param {Object<string, ValidationConfig>} validation The validation configuration object.
* The keys of this object correspond to names of fields in the form, and the values are the
* corresponding validation.
* @param {string} [wizardName=null] The name of the enhancedFormWizard this form is part of
* @returns {function} Function that will be handled by redux-thunk
*/
export const registerForm = createAction(
REGISTER_FORM,
(form, validation = {}) => {
const validationFields = Object.keys(validation);
// An enhancedFormWizard is also registered as a enhancedForm, but it has no 'form property, just 'name'. I couldn't find where to pass through it's name as form.
if (typeof form === 'undefined' && WP_DEFINE_DEVELOPMENT) {
console.warn(
'Unless this is an enhancedFormWizard - you must pass the property \'form\' to enhancedFormConfig e.g. enhancedReduxForm({ form: "formName" })',
);
}
// will reduce to form: { [eventName]: [fieldName1, fieldName2, ...] }
const validateOn = validationFields.reduce((_validateOn, fieldName) => {
/* eslint-disable no-param-reassign */
if (validation[fieldName].validateOn) {
[].concat(validation[fieldName].validateOn).forEach(eventName => {
if (!_validateOn[eventName]) {
_validateOn[eventName] = [];
}
_validateOn[eventName].push(fieldName);
});
}
/* eslint-enable */
return _validateOn;
}, {});
return { validateOn };
},
(form, _, wizardName) => ({ form, wizardName }),
);
/**
* __Note: this action is used internally by enhanced-redux-form. You will probably not
* need this for general usage__
*
* Called by enhancedReduxFormMiddleware whenever the destroy() action of redux-form is called.
* Will cause the attached enhanced state to be destroyed as well
* @function destroyEnhancedForm
* @param {string} name The name of the form that is being unmounted
* @returns {object} The action
*/
export const destroyEnhancedForm = createAction(
DESTROY_ENHANCED_FORM,
() => ({}),
form => ({ form }),
);
/**
* Removes validation from the form on the specified fields
* @function clearValidation
* @param {string} form The name of the form
* @param {Array<string>} [fields] The fields to remove validation from. If not
* specified, removes from all fields (including any general form error)
* @returns {object} The action
*/
export const clearValidation = createAction(
CLEAR_VALIDATION,
(form, fields) => ({
fields,
}),
form => ({ form }),
);
/**
* __Note: this action is used internally by enhanced-redux-form. You will probably not
* need this for general usage__
*
* Called by the enhancedReduxFormMiddleware to indicate that certain fields of a form
* should be validated. This state is picked up by the decorated form to trigger validation.
* @function formShouldValidate
* @param {string} form The name of the form
* @param {Array<string>} fields An array of fields that should be validated.
* @returns {object} The action
*/
export const formShouldValidate = createAction(
FORM_SHOULD_VALIDATE,
(form, fields) => ({
fields,
}),
form => ({ form }),
);
export function findWizardWithForm(state, form) {
const wizardState = state.enhancedForm.wizard;
const wizardNames = Object.keys(wizardState);
for (let i = 0; i < wizardNames.length; i++) {
const stepIndex = wizardState[wizardNames[i]].steps.findIndex(step => step.form === form);
if (stepIndex >= 0) {
return {
stepIndex,
wizardName: wizardNames[i],
};
}
}
return null;
}
/**
* Extracts the given field from the given form values. If the field name is separated
* by dots, do a deep lookup in the form values.
* @param {object} formValues Object containing all form values
* @param {string} fieldName Name of field to retrieve
* @returns The value or undefined if it is not found
*/
function extractFormValue(formValues, fieldName) {
const parts = fieldName.split('.');
if (parts.length < 2) {
return formValues[fieldName];
}
return parts.reduce((subValues, part) => {
if (typeof subValues === 'undefined' || subValues === null) {
return subValues;
}
return subValues[part];
}, formValues);
}