Last active
July 2, 2019 14:11
-
-
Save lpolito/71e83bbdf5252845102071aae37deadc to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import isEqual from 'lodash/isEqual.js'; | |
import {useComplexState} from 'hooks/use-complex-state.mjs'; | |
import {useOnUpdateEffect} from 'hooks/use-on-update-effect.mjs'; | |
// FormItems contexts | |
const FormItemsContext = React.createContext(); | |
const FormActionsContext = React.createContext(); | |
const getMatchingFormItemEntry = (keyToFind, formItems) => ( | |
// Get form item entries by matching key. | |
Object.entries(formItems).find(([key]) => key === keyToFind) | |
); | |
const formItemsReducer = (prevItems, payload) => { | |
switch (payload.action) { | |
case 'register': | |
case 'sync': { | |
// formItemToPersist will be empty object if doesn't exist yet. | |
const formItemToPersist = prevItems[payload.key] | |
? prevItems[payload.key] | |
: { | |
// Make sure subscriptions are on all form items. | |
subscriptions: [], | |
}; | |
const updatedFormItem = { | |
...formItemToPersist, | |
...payload.formItem, | |
}; | |
// currentValue can be undefined if this was a new formItem. | |
const currentValue = formItemToPersist.localState ? formItemToPersist.localState.value : undefined; | |
// Call form item's subscriptions if needed. | |
if (updatedFormItem.subscriptions.length | |
&& !isEqual(currentValue, payload.formItem.localState.value)) { | |
// Value changed between syncs, call subscription with latest value / state. | |
formItemToPersist.subscriptions.forEach((callback) => { | |
callback(updatedFormItem.localState.value, updatedFormItem.localState); | |
}); | |
} | |
return { | |
...prevItems, | |
[payload.key]: updatedFormItem, | |
}; | |
} | |
case 'unregister': { | |
const {[payload.key]: removed, ...rest} = prevItems; | |
return rest; | |
} | |
case 'addSubscription': { | |
// When adding a new subscription check if the subscribed key already exists. | |
// If it does, call the new callback with the pertinent value / state so the subscriber is always up to date. | |
const [key, formItem] = getMatchingFormItemEntry(payload.key, prevItems) || []; | |
if (key && formItem) { | |
// localState is undefined if a form item is subscribed to but hasn't been init'd yet. | |
if (formItem.localState) { | |
payload.callback(formItem.localState.value, formItem.localState); | |
} | |
// Update existing form items subscriptions with additional callback. | |
const updateFormItem = { | |
[key]: { | |
...formItem, | |
subscriptions: [...formItem.subscriptions, payload.callback], | |
}, | |
}; | |
return { | |
...prevItems, | |
...updateFormItem, | |
}; | |
} | |
// Create a new formItem with the given key. | |
// This happens when a subscription is added before a formItem with the given key is initialized. | |
// This is making the assumption that the given key will be valid at some point. | |
const newFormItem = { | |
[payload.key]: { | |
subscriptions: [payload.callback], | |
}, | |
}; | |
return { | |
...prevItems, | |
...newFormItem, | |
}; | |
} | |
case 'removeSubscription': { | |
// Update existing form items by removing subscription. | |
const [key, formItem] = getMatchingFormItemEntry(payload.key, prevItems) || []; | |
const updateFormItem = { | |
[key]: { | |
...formItem, | |
subscriptions: formItem.subscriptions.filter((callback) => callback !== payload.callback), | |
}, | |
}; | |
return { | |
...prevItems, | |
...updateFormItem, | |
}; | |
} | |
case 'touch': | |
// Call internal touch functions. | |
Object.values(prevItems).forEach((formItem) => formItem.touch()); | |
return prevItems; | |
case 'reset': | |
// Call internal reset functions. | |
Object.values(prevItems).forEach((formItem) => formItem.reset()); | |
return prevItems; | |
default: | |
return prevItems; | |
} | |
}; | |
const useFormItems = () => React.useContext(FormItemsContext); | |
export const useFormActions = () => React.useContext(FormActionsContext); | |
const FormItemsProvider = (props) => { | |
const [formItems, dispatch] = React.useReducer(formItemsReducer, {}); | |
// In practice actionsContext should never be recalculated because dispatch is stable. | |
// This is important because FormItemActionsContext consumers shouldn't rerender on formItems changes. | |
const actionsContext = React.useMemo(() => ({ | |
register: (key, formItem) => { | |
dispatch({action: 'register', key, formItem}); | |
return () => dispatch({action: 'unregister', key}); | |
}, | |
sync: (key, formItem) => dispatch({action: 'sync', key, formItem}), | |
subscribeByKey: (key, callback) => { | |
dispatch({action: 'addSubscription', key, callback}); | |
return () => { | |
dispatch({action: 'removeSubscription', key, callback}); | |
}; | |
}, | |
touch: () => dispatch({action: 'touch'}), | |
reset: () => dispatch({action: 'reset'}), | |
}), [dispatch]); | |
return ( | |
<FormItemsContext.Provider value={formItems}> | |
<FormActionsContext.Provider value={actionsContext} {...props} /> | |
</FormItemsContext.Provider> | |
); | |
}; | |
// FormValues context | |
const FormValuesContext = React.createContext(); | |
export const useFormValues = () => React.useContext(FormValuesContext); | |
// Create object of key:values of all form items. | |
const reduceFormValues = (formItems) => ( | |
Object.entries(formItems) | |
.reduce((acc, formItemEntry) => { | |
const [key, formItem] = formItemEntry; | |
// localState is undefined if a form item is subscribed to but hasn't been init'd yet. | |
if (!formItem.localState) return acc; | |
const {localState} = formItem; | |
// Ignore disabled fields. | |
if (localState.disabled) return acc; | |
return { | |
...acc, | |
[key]: localState.value, | |
}; | |
}, {}) | |
); | |
const FormValuesProvider = (props) => { | |
const formItems = useFormItems(); | |
const formValues = reduceFormValues(formItems); | |
return ( | |
<FormValuesContext.Provider value={formValues} {...props} /> | |
); | |
}; | |
// Form validity context | |
const FormValidityContext = React.createContext(); | |
export const useFormValidity = () => React.useContext(FormValidityContext); | |
const FormValidityProvider = ({validator, ...props}) => { | |
const formItems = useFormItems(); | |
// localState is undefined if a form item is subscribed to but hasn't been init'd yet. | |
const formItemValues = Object.values(formItems).filter((formItem) => formItem.localState); | |
const formItemsValid = formItemValues.every(({localState}) => localState.valid); | |
const formIsDirty = formItemValues.some(({localState}) => localState.dirty); | |
const formValues = useFormValues(); | |
const formErrors = validator(formValues); | |
const context = React.useMemo(() => ({ | |
valid: formItemsValid && !formErrors, | |
formErrors, | |
dirty: formIsDirty, | |
}), [formItemsValid, formErrors, formIsDirty]); | |
return ( | |
<FormValidityContext.Provider value={context} {...props} /> | |
); | |
}; | |
FormValidityProvider.propTypes = { | |
validator: PropTypes.func.isRequired, | |
}; | |
export const FormProvider = ({validator, ...rest}) => ( | |
<FormItemsProvider> | |
<FormValuesProvider> | |
<FormValidityProvider validator={validator} {...rest} /> | |
</FormValuesProvider> | |
</FormItemsProvider> | |
); | |
FormProvider.propTypes = { | |
validator: PropTypes.func, | |
}; | |
FormProvider.defaultProps = { | |
validator: () => null, | |
}; | |
const defaultFormItemState = ({value, ...props}) => ({ | |
// Initial value of field - can be any type or shape. | |
value, | |
// If a user has changed a field at all - even if it means changing it back to its original state. | |
// Only returns to false when form item's initial value is updated via props. | |
dirty: false, | |
// If a user has interacted with the field. | |
touched: false, | |
// If the field doesn't pass provided validator - can be any type or shape as long form field can ingest it. | |
// Null representing no error. | |
error: null, | |
// If a field is disabled. Disabled fields are not validated. | |
disabled: false, | |
// If field is valid. | |
valid: true, | |
...props, | |
}); | |
const getValidityState = (localState, validator) => { | |
// If there is no validator specified, always assume valid. | |
if (validator === undefined) { | |
return { | |
valid: true, | |
error: null, | |
}; | |
} | |
const error = localState.disabled ? null : validator(localState.value); | |
return { | |
valid: !error, | |
error, | |
}; | |
}; | |
/** | |
* @param {Object} options | |
* @param {string} options.key Unique key that identifies this field. | |
* @param {any} options.value Initial value of field. Can be any type or shape. | |
* @param {string} options.validator Function which accepts value + form values to signify item validity. | |
*/ | |
export const useFormItem = ({ | |
key, value: initialValue = '', validator, | |
}) => { | |
const {register, sync} = useFormActions(); | |
const [localState, setLocalState] = useComplexState(() => { | |
const defaultState = defaultFormItemState({value: initialValue}); | |
return { | |
...defaultState, | |
...getValidityState(defaultState, validator), | |
}; | |
}); | |
const validate = () => { | |
setLocalState(getValidityState(localState, validator)); | |
}; | |
const update = (newValue) => { | |
// Reducer function is being used to derive new state from previous state. | |
setLocalState((prevState) => { | |
const updatedState = { | |
...prevState, | |
value: typeof newValue === 'function' ? newValue(prevState.value) : newValue, | |
dirty: true, | |
}; | |
// Bake in validation on value update. | |
return { | |
...updatedState, | |
...getValidityState(updatedState, validator), | |
}; | |
}); | |
}; | |
const disable = (disabled) => setLocalState({disabled}); | |
const touch = () => setLocalState({touched: true}); | |
// Always append validityState so validity is up to date. | |
const reset = (newValue = initialValue) => { | |
const defaultState = defaultFormItemState({value: newValue}); | |
setLocalState({ | |
...defaultState, | |
...getValidityState(defaultState, validator), | |
}); | |
}; | |
useOnUpdateEffect(() => { | |
// Reset form item when initial value changes. | |
reset(); | |
}, [initialValue]); | |
React.useEffect(() => ( | |
// On init this registers the form item to context and unregister on unmount (register resturns a function). | |
register(key, { | |
localState, | |
validate, | |
reset, | |
touch, | |
}) | |
), []); | |
useOnUpdateEffect(() => { | |
// On localState change this lets parent context know the latest localState. | |
sync(key, { | |
localState, | |
validate, | |
reset, | |
touch, | |
}); | |
}, [localState, validator]); | |
React.useEffect(() => { | |
validate(); | |
}, [localState.touched, localState.disabled, validator]); | |
return { | |
...localState, | |
shouldShowError: (localState.dirty || localState.touched) && !localState.valid, | |
validate, | |
update, | |
disable, | |
touch, | |
reset, | |
}; | |
}; | |
export const useFormValue = (key) => { | |
const {subscribeByKey} = useFormActions(); | |
const [value, setValue] = React.useState(null); | |
React.useEffect(() => ( | |
subscribeByKey(key, (newValue) => setValue(newValue)) | |
), []); | |
return value; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment