react-formstate-fp
Version:
An API to build, query, update, and bind formstate in React. Develop forms DRYly in React.
493 lines (355 loc) • 15.2 kB
JavaScript
import {
exists, isObject, isNonEmptyString, inScope, sortKeysByScopeLength, modelGet, modelAdd, modelDelete, addScope, parseParentScope, parseRootScope
} from './helperFunctions.js';
import {
convertToRootModelKey, getRootModelKey, getModelKey, getId, normalizeSchemaKeys, buildLookup, buildValidationSchemas, isScope, createNestedScope
} from './schemaAndLookup.js';
import {
getValue, isSynclyInvalid, isSynclyValidated, isWaiting, isAsynclyValidated, isChanged, isBlurred, isSubmitting,
setValueAndClearStatus, setSynclyValid, setSynclyInvalid, setPromise, setChanged, setBlurred, clearStatus, setTouched
} from './fieldAndScopeStatus.js';
import {
isInputDisabled, isFormSubmitting, isModelValid, setFormSubmitting, setFormSubmitted, setInputDisabled, setInputEnabled
} from './formStatus.js';
//
// synclyValidate
//
export function synclyValidate(formstate, modelKey, form, id = null) {
if (!id) {
id = getId(formstate, modelKey);
}
if (!isSynclyValidated(formstate, modelKey)) {
formstate = _synclyValidate(formstate, modelKey, form, id);
}
// validate container scopes, in order of precision, with the root scope last.
const rootModelKey = getRootModelKey(formstate, id);
// Cannot use a relative model key for scopes above the nested scope...
const nestedScopeId = formstate.nestedScopeId;
formstate = {...formstate, nestedScopeId: null};
sortKeysByScopeLength(formstate.lookup.scopes, formstate.lookup.rootModelKeysById).forEach(rootScopeKey => {
if (inScope(rootModelKey, rootScopeKey)) {
if (!isSynclyValidated(formstate, rootScopeKey)) {
formstate = _synclyValidate(formstate, rootScopeKey, form, formstate.lookup.idsByRootModelKey[rootScopeKey]);
}
}
});
return {...formstate, nestedScopeId};
}
//
// synclyValidateForm
//
export function synclyValidateForm(formstate, form) {
// Cannot use a relative model key for scopes above the nested scope...
const nestedScopeId = formstate.nestedScopeId;
formstate = {...formstate, nestedScopeId: null};
sortKeysByScopeLength(formstate.lookup.rootModelKeysById, formstate.lookup.rootModelKeysById).forEach(rootModelKey => {
if (!isSynclyValidated(formstate, rootModelKey)) {
const id = formstate.lookup.idsByRootModelKey[rootModelKey];
formstate = _synclyValidate(formstate, rootModelKey, form, id);
}
});
return {...formstate, nestedScopeId};
}
export const validateForm = synclyValidateForm;
export function _synclyValidate(formstate, modelKey, form, id) {
const validationSchema = formstate.validationSchemas[id] || (form.validationSchemas && form.validationSchemas[id]);
if (!validationSchema) {return setSynclyValid(formstate, modelKey);}
const value = getValue(formstate, modelKey);
if (validationSchema.required) {
const result = validateRequired(value, validationSchema.requiredMessage);
if (result) {
return setSynclyInvalid(formstate, modelKey, result);
}
}
if (validationSchema.validate) {
const nestedScopeId = formstate.nestedScopeId;
let scopedFormstate = formstate, scopedForm = form;
if (formstate.nestedScopeId !== validationSchema.nestedScopeId) {
[scopedFormstate, scopedForm] = createNestedScope(validationSchema.nestedScopeId, formstate, form);
}
const result = validationSchema.validate(value, scopedFormstate, scopedForm, id);
if (isNonEmptyString(result)) {
return setSynclyInvalid(formstate, modelKey, result);
}
if (isObject(result) && result.model) {
formstate = {...result, nestedScopeId};
if (isScope(formstate, id) && !isSynclyValidated(formstate, modelKey)) {
formstate = setSynclyValid(formstate, modelKey);
}
return formstate;
}
if (result) {
throw new Error('Validation functions should return an error message, an updated formstate object, or nothing.');
}
}
return setSynclyValid(formstate, modelKey);
}
export function validateRequired(value, configuredMessage) {
if (!exists(value)) {
return configuredMessage;
}
if (typeof(value) === 'string' && value.trim() === '') {
return configuredMessage;
}
if (Array.isArray(value) && value.length === 0) {
return configuredMessage;
}
}
//
// asynclyValidate
//
export function asynclyValidate(formstate, modelKey, form, id = null) {
if (isSynclyInvalid(formstate, modelKey) || isAsynclyValidated(formstate, modelKey) || isWaiting(formstate, modelKey)) {
return formstate;
}
if (!id) {
id = getId(formstate, modelKey);
}
const validationSchema = formstate.validationSchemas[id] || (form.validationSchemas && form.validationSchemas[id]);
const validateAsync = validationSchema && validationSchema.validateAsync;
if (!validateAsync) {
return formstate;
}
const [validateAsyncFunction, whenToRun] = validateAsync;
if (whenToRun === 'onSubmit' && !isSubmitting(formstate, modelKey)) {
return formstate;
}
if (whenToRun === 'onBlur' && !isBlurred(formstate, modelKey) && !isSubmitting(formstate, modelKey)) {
return formstate;
}
const nestedScopeId = formstate.nestedScopeId;
let scopedFormstate = formstate, scopedForm = form;
if (formstate.nestedScopeId !== validationSchema.nestedScopeId) {
[scopedFormstate, scopedForm] = createNestedScope(validationSchema.nestedScopeId, formstate, form);
}
const result = validateAsyncFunction(getValue(formstate, modelKey), scopedFormstate, scopedForm, id);
const asyncResultErrorMessage = 'An asynchronous validation function should return a formstate object to update formstate synchronously, or an array of [formstate, asyncToken, promise] to proceed with asynchronous validation.';
if (!isObject(result) && !Array.isArray(result)) {
throw new Error(asyncResultErrorMessage);
}
if (isObject(result)) {
return {...result, nestedScopeId};
}
const [waitingFormstate, asyncToken, promise] = result;
if (!isObject(waitingFormstate) || !isNonEmptyString(asyncToken) || !isObject(promise) || !exists(promise.constructor) || promise.constructor.name !== 'Promise') {
throw new Error(asyncResultErrorMessage);
}
return setPromise({...waitingFormstate, nestedScopeId}, asyncToken, promise);
}
//
// asynclyValidateForm
//
export function asynclyValidateForm(formstate, form) {
// Cannot use a relative model key for scopes above the nested scope...
const nestedScopeId = formstate.nestedScopeId;
formstate = {...formstate, nestedScopeId: null};
Object.keys(formstate.lookup.rootModelKeysById).forEach(id => {
const rootModelKey = getRootModelKey(formstate, id);
formstate = asynclyValidate(formstate, rootModelKey, form, id);
});
return {...formstate, nestedScopeId};
}
//
// getPromises
//
export function getPromises(formstate) {
return Object.keys(formstate.formStatus.promises).map((asyncToken) => formstate.formStatus.promises[asyncToken]);
}
//
// changeAndValidate
//
export function changeAndValidate(formstate, modelKey, value, form, id = null) {
formstate = setValueAndClearStatus(formstate, modelKey, value);
formstate = setChanged(formstate, modelKey);
formstate = synclyValidate(formstate, modelKey, form, id);
return asynclyValidate(formstate, modelKey, form, id);
}
//
// handlers
//
export function handleChange(form, value, id) {
form.setFormstate((formstate) => {
if (isInputDisabled(formstate)) {return formstate;}
return changeAndValidate(formstate, getModelKey(formstate, id), value, form, id);
});
}
export function handleBlur(form, id) {
form.setFormstate((formstate) => {
if (isInputDisabled(formstate)) {return formstate;}
const modelKey = getModelKey(formstate, id);
formstate = setBlurred(formstate, modelKey);
if (!isFormSubmitting(formstate)) { // && !isFormSubmittedAndUnchanged(formstate)) {
if (form.validateOnBlur) {
formstate = synclyValidate(formstate, modelKey, form, id);
formstate = asynclyValidate(formstate, modelKey, form, id);
}
else if (isChanged(formstate, modelKey)) {
// If the form is set to NOT validate onBlur, and if they tab right through it, nothing should happen.
// In that case, if there is async validation to run, at worst it'll run on submit.
formstate = asynclyValidate(formstate, modelKey, form, id);
}
}
return formstate;
});
}
//
// startFormSubmission
//
export function startFormSubmission(formstate) {
if (formstate.nestedScopeId) {
throw new Error('Nested form components should not submit the form.');
}
if (isFormSubmitting(formstate)) {
throw new Error('The formstate provided to startFormSubmission is already submitting! Please check for this before you call startFormSubmission.');
}
return setInputDisabled(setFormSubmitting(formstate));
}
//
// cancelFormSubmission
//
export function cancelFormSubmission(formstate) {
return setInputEnabled(setFormSubmitted(formstate));
}
//
// driveFormSubmission
//
export function driveFormSubmission(form, submitValidModel) {
form.setFormstate((formstate) => {
if (isFormSubmitting(formstate)) {return formstate;}
formstate = startFormSubmission(formstate);
formstate = synclyValidateForm(formstate, form);
// if (isModelInvalid(formstate)) {return cancelFormSubmission(formstate);}
formstate = asynclyValidateForm(formstate, form);
Promise.all(getPromises(formstate)).then(() => {
form.setFormstate((validatedFs) => {
if (!isModelValid(validatedFs)) {return cancelFormSubmission(validatedFs);}
Promise.resolve().then(() => {
submitValidModel(validatedFs.model, form);
});
return validatedFs;
});
});
return formstate;
});
}
//
// addModelKey
//
export function addModelKey(formstate, modelKey, initialModel, formValidationSchema = {}) {
const rootModelKey = convertToRootModelKey(formstate, modelKey);
formstate = {
...formstate,
lookup: {
...formstate.lookup,
rootModelKeysById: {...formstate.lookup.rootModelKeysById},
idsByRootModelKey: {...formstate.lookup.idsByRootModelKey},
scopes: {...formstate.lookup.scopes}
},
validationSchemas: {...formstate.validationSchemas},
model: modelAdd(formstate.model, rootModelKey, initialModel)
// formStatus
// statuses
// nestedScopeId
};
const normalizedFormValidationSchema = {
fields: {},
scopes: {}
};
normalizeSchemaKeys(formValidationSchema, normalizedFormValidationSchema, 'fields', rootModelKey);
normalizeSchemaKeys(formValidationSchema, normalizedFormValidationSchema, 'scopes', rootModelKey);
buildLookup(formstate, rootModelKey, initialModel, normalizedFormValidationSchema);
buildValidationSchemas(formstate, normalizedFormValidationSchema, 'fields');
buildValidationSchemas(formstate, normalizedFormValidationSchema, 'scopes');
// Cannot use a relative model key for scopes above the nested scope...
const nestedScopeId = formstate.nestedScopeId;
formstate = {...formstate, nestedScopeId: null};
formstate = _setContainerScopesChanged(formstate, rootModelKey);
return {...formstate, nestedScopeId};
}
//
// deleteModelKey
//
export function deleteModelKey(formstate, modelKey) {
const rootModelKeyToDelete = convertToRootModelKey(formstate, modelKey);
const purgedFormstate = {
...formstate,
lookup: {
rootModelKeysById: {},
idsByRootModelKey: {},
scopes: {}
},
statuses: {},
validationSchemas: {},
model: modelDelete(formstate.model, rootModelKeyToDelete)
// formStatus
// nestedScopeId
};
const [parentScope, i] = parseParentScope(rootModelKeyToDelete);
const deletingFromArray = Array.isArray(modelGet(formstate.model, parentScope));
Object.keys(formstate.lookup.idsByRootModelKey).forEach(rootModelKey => {
if (!inScope(rootModelKey, rootModelKeyToDelete)) {
const id = formstate.lookup.idsByRootModelKey[rootModelKey];
let shiftedRootModelKey = null;
if (deletingFromArray && inScope(rootModelKey, parentScope) && rootModelKey !== parentScope) {
const indexAndRemainder = rootModelKey.slice(parentScope.length + 1);
const [j, remainder] = parseRootScope(indexAndRemainder);
if (Number(j) > Number(i)) {
const shiftedIndex = String(Number(j) - 1);
const shiftedIndexAndRemainder = addScope(shiftedIndex, remainder);
shiftedRootModelKey = addScope(parentScope, shiftedIndexAndRemainder);
}
}
purgedFormstate.lookup.rootModelKeysById[id] = shiftedRootModelKey || rootModelKey;
purgedFormstate.lookup.idsByRootModelKey[shiftedRootModelKey || rootModelKey] = id;
if (formstate.lookup.scopes[id]) {
purgedFormstate.lookup.scopes[id] = true;
}
const status = formstate.statuses[id];
if (status) {
purgedFormstate.statuses[id] = status;
}
const validationSchema = formstate.validationSchemas[id];
if (validationSchema) {
purgedFormstate.validationSchemas[id] = validationSchema;
}
}
});
// Cannot use a relative model key for scopes above the nested scope...
const nestedScopeId = formstate.nestedScopeId;
formstate = {...purgedFormstate, nestedScopeId: null};
formstate = _setContainerScopesChanged(formstate, rootModelKeyToDelete);
return {...formstate, nestedScopeId};
}
// This assumes nestedScopeId already set to null.
// This is to handle a case where you delete the nested scope.
//
export function _setContainerScopesChanged(formstate, rootModelKey) {
const [parentScope] = parseParentScope(rootModelKey);
Object.keys(formstate.lookup.scopes).forEach(scopeId => {
const rootScopeKey = getRootModelKey(formstate, scopeId);
if (inScope(parentScope, rootScopeKey)) {
formstate = clearStatus(formstate, rootScopeKey);
formstate = setTouched(formstate, rootScopeKey, 'changed');
}
});
return formstate;
}
// Too easy to mess up the signature when calling this. It's easy enough to just call synclyValidate yourself...
//
// export function addModelKeyAndValidate(formstate, modelKey, form, initialModel, formValidationSchema = {}) {
// formstate = addModelKey(formstate, modelKey, initialModel, formValidationSchema);
// return synclyValidate(formstate, modelKey, form);
// }
// In this case, you cannot get to the parent scope in a nested form without a whole lot of hassle, so this one is more helpful.
//
export function deleteModelKeyAndValidateParentScope(formstate, modelKey, form) {
// Do this before the nestedScopeId modelKey potentially gets deleted.
const rootModelKey = convertToRootModelKey(formstate, modelKey);
const [parentScope] = parseParentScope(rootModelKey);
// Cannot use a relative model key for scopes above the nested scope...
const nestedScopeId = formstate.nestedScopeId;
formstate = {...formstate, nestedScopeId: null};
formstate = deleteModelKey(formstate, rootModelKey);
formstate = synclyValidate(formstate, parentScope, form, formstate.lookup.idsByRootModelKey[parentScope]);
return {...formstate, nestedScopeId};
}