UNPKG

modules-pack

Version:

JavaScript Modules for Modern Frontend & Backend Projects

618 lines (562 loc) 27.1 kB
import { UI } from 'modules-pack/variables' import { fieldsFrom } from 'modules-pack/variables/fields' import PropTypes from 'prop-types' import React, { PureComponent } from 'react' import { Field, Form } from 'react-final-form' import { tooltipProps } from 'react-ui-pack' import { isRequired } from 'react-ui-pack/inputs/validationRules' import Text from 'react-ui-pack/Text' import ToolTip from 'react-ui-pack/Tooltip' import TooltipPop from 'react-ui-pack/TooltipPop' import View from 'react-ui-pack/View' import { Active, debounce, isEqualJSON, toJSON, warn } from 'utils-pack' import { hasObjectValue, objChanges, set } from 'utils-pack/object' import { _ } from 'utils-pack/translations' /** * STATE SELECTORS ============================================================= * Memoized Functions - to retrieve specific branches of the app state * ============================================================================= */ /** * Get Form's Field Values * @param {Object} form - instance from react-final-form * @return {Object} formValues - key values of field names and values */ export function fieldValues (form) { return form.getState().values } /** * Get Form's Registered Field Values * * @param {FormApi} form - instance from react-final-form * @returns {Object|Undefined} values - nested mapping of field values by their name, or `false` if no field values found */ export function registeredFieldValues (form) { const registeredFieldNames = form.getRegisteredFields() if (!registeredFieldNames.length) return // Return object mapping of registered values, // unfilled fields are considered as non-registered. const values = {} registeredFieldNames.forEach(field => { const {value} = form.getFieldState(field) if (value != null) set(values, field, value) // use set() to convert nested paths to objects }) if (hasObjectValue(values)) return values } /** * Get Form's Registered Field Errors * * @param {FormApi} form - instance from react-final-form * @returns {Object|Undefined} errors - key values of field names and error messages */ export function registeredFieldErrors (form) { const registeredFieldNames = form.getRegisteredFields() if (!registeredFieldNames.length) return // Return object mapping of registered field errors, // unfilled fields are considered as non-registered. const errors = {} registeredFieldNames.forEach(field => { const {error} = form.getFieldState(field) if (error != null) errors[field] = error }) if (hasObjectValue(errors)) return errors } /** * HELPER FUNCTIONS ============================================================ * ============================================================================= */ /** * Wrapper Proxy for react-final-form or redux-form Field with unified API. * @Note: * - `normalize` does not exist in react-final-form, only `format` and `parse` * => Make `format` and `parse` fallback to `normalize`, when undefined, * for compatibility with redux-form. * - must use Class to prevent input from loosing focus on input 'onChange' * * @param InputComponent - React component to use for input * @param {Function} sanitize(value, props) - callback to parse (formatted) value from input Field to InputComponent * @returns {Class} React InputComponentField - connected to react-final-form or redux-form */ export function asField (InputComponent, {sanitize} = {}) { if (!Active.Field) Active.Field = Field // noinspection JSPotentiallyInvalidUsageOfThis const Class = class extends PureComponent { static propTypes = { // Input `name` attribute name: PropTypes.string.isRequired, // Instance of the Class component decorated withFormSetup (i.e withForm) instance: PropTypes.object, // Whether to fire Field.onChange(null) when its component unmounts onRemoveChange: PropTypes.bool, label: PropTypes.any, id: PropTypes.string, // HTML Input type attribute type: PropTypes.string, // Input placeholder placeholder: PropTypes.any, // help text or component to show on focus info: PropTypes.any, // help text or component to show on invalid input error: PropTypes.any, value: PropTypes.any, onChange: PropTypes.func, format: PropTypes.func, normalize: PropTypes.func, parse: PropTypes.func, } get value () { if (this._value !== void 0) return this._value return '' } set value (v) { this._value = v } componentDidMount () { this.didMount = true } // Handle onRemove field in FIELD.TYPE.MULTIPLE* componentWillUnmount () { // warn('-------componentWillUnmount', this.constructor.name) // Call onChange for the deleted input, setting it to `null`: // - if input is not registered, its value will not pass to backend // => this should be fine, because if registeredValues are used, // then backend should override the entire object (i.e. removing unregistered fields automatically). // - if changedValues are used, the deleted `null` value will be sent to backend, // because changedValues does not depend on registered values. // Use setTimeout to avoid triggering `valid: false` for required fields // @scenario: // - onChange(null) triggers `valid: false` for FIELD.TYPE.MULTIPLE with `required`, thus canSave gets disabled // => to fix it, need to call onChange(null) after input unmounts, or disable validation temporarily // => both cases do not update `pristine`, so cannot rely on this for `canSave` state. // @Note: // - this.props.onChange is callback defined in withFormSetup - does not update form values, or change `pristine` // - this.input.onChange is callback from final-form/redux-form - does not trigger parent re-render directly, only when `valid` prob changes // => the best logic is to change input value after it unmounts, and call `onChange` to update parent state, // because this avoids validation, ties all operations together and persists `state.canSave`. const {instance, name, onChange, onRemoveChange} = this.props if (instance && onRemoveChange) { const initialValues = this.initValues setTimeout(() => { // only call this if the form is not unmounted and initialValues remained (i.e. not between transitions) if (instance.isUnmounting) return const form = instance.form if (form && initialValues === instance.props.initialValues) { form.change(name, null) onChange && onChange(null) // update Save button state } }, 0) } } // do not use ...props from input, because it is shared by <Active.Field> instances // @Note: react-final-form fires `format()` when `input.value` getter is called Input = ({input: {value, ...input}, meta: {touched, error, pristine} = {}}) => { const { onChange, error: err, defaultValue, normalize, format, parse, validate, instance, onRemoveChange, ...props } = this.props if (!this.hasFocus) { // use cached `value` while editing to prevent format/parse bugs and rerender // @Note: defaultValue is only used for UI, internal value is still undefined this.value = value === void 0 ? (pristine && defaultValue != null ? (format ? format(defaultValue) : defaultValue) : value) : value } // Hide this field if it's readonly and has no value. if (this.props.readonly && isRequired(this.value != null ? this.value : this.props.value)) return null this.input = input if (instance) this.initValues = instance.props.initialValues return ( <InputComponent {...input} value={sanitize ? sanitize(this.value, this.props) : this.value} onFocus={this.handleFocus} onBlur={this.handleBlur} // prevent value change, but need onBlur to set touched for validation onChange={this.handleChange} error={error && (touched || !pristine) && (err || error)} // only show error after user interaction {...props} // allow forceful value override /> ) } handleFocus = (...args) => { this.hasFocus = true return this.input.onFocus(...args) } handleBlur = () => { this.hasFocus = false return this.input.onBlur() } handleChange = (value, ...args) => { const {onChange, type, normalize, parse = normalize} = this.props /** * @Note: * - `parse` gets called by final-form automatically on input.onChange, * but `formatOnBlur` (needed to prevent cursor jumping) only calls format onBlur, * even if input did not change. This causes extra call on `format` when input did not change, * and doesn't call `format` when `parse` was called onChange. * => the solution is to cache `value` internally to prevent Input rerender while in focus, * and remove `formatOnBlur` because it's buggy behavior (does not format on initial mount). */ if (this.hasFocus) { this.value = value // store value exactly as typed in (example: value of '1.0' to work nicely with `unit` = '%') } if (type === 'number') value = value !== '' ? Number(value) : null this.input.onChange(value) // both redux-form and final-form input.onChange can accept 'event' or 'value' onChange && onChange(parse ? parse(value) : value, ...args) } // @Note: this is not needed as default behavior, because inputs like color always trigger onChange // the logic was used for redux-form to normalize input initially // componentDidMount () { // // Normalize initialValue // const {normalize, parse = normalize, onChange} = this.props // if (!parse || this.value === '') return // const valueNormalized = parse(this.value) // if (this.value === valueNormalized) return // this.input.onChange(valueNormalized) // onChange && onChange(valueNormalized) // } // Do not pass 'onChange' to Field because it fires event as argument // final-form does not take controlled `value` render () { const { name, disabled, normalize, format, parse = normalize, validate, options } = this.props return <Active.Field {...{name, disabled, normalize, format, parse, validate, options}} component={this.Input}/> } } Object.defineProperty(Class, 'name', {value: InputComponent.name || InputComponent.constructor.name}) return Class } /** * React Component React Final Form Decorator with getters to detect form input changes * @note: * - cannot wrap connected to redux component, @connect must be declared before * - onSubmit can be passed to the decorated Class component * @example: * *@withForm() * class SigninForm extends PureComponent {} * // later in the render() * <SigninForm onSubmit={(formValues, form, callback: ?(errors?) => void) => ?Object | Promise<?Object> | void}/> * // see https://final-form.org/docs/react-final-form/types/FormProps#onsubmit * * @usage: * Below methods only work when using this.renderInput(FIELD.FOR.LIST), * or apply <Input onChange={this.handleChangeInput.bind(this)}/> manually: * - this.canSave - getter boolean: true if form has input changes, no validation error exists, and is not loading * - this.changedValues - getter object: key value pairs of form input values that have changed since initial values * - this.registeredValues - getter object: key value pairs of registered form input values * - this.changedAndRegisteredValues - getter object: combination of above * - this.formValues - getter object: key value pairs of all form input values * - this.renderInput - function: to render form inputs using FIELD.FOR.LIST definition (hooks `onChange` to inputs). * * @helpers: * - this._fields - list: of FIELD.FOR.LIST hydrated with props and initialValues, ready for rendering. * - this.handleChangeInput() - function: updates state.canSave (hooked to this.renderInput, must be defined as function) * - this.syncInputChanges() - function: can be called manually to update input changes state, and force re-rendering * - this.props.tooltip - object|boolean: automatically wrap rendered input with Semantic UI Popup * - this.props.onChangeState - function: callback when internal state changes, receives this class instance, * or {} on unmount. This is useful for nested forms with remote submit button within parent container. * * @example: * @connect(mapStateToProps) * @withForm({subscription: {pristine: true, valid: true}}) * export default class UserEdit extends Component { * state = { * company: {} * } * render = () => ( * <View> * <Company onChangeState={(instance) => this.setState({company: instance})} /> * <Button disabled={!this.state.company.canSave}>Save<Button/> * </View> * ) * } * * @param {FormProps|Object} [options] - for <Form/> see: https://final-form.org/docs/react-final-form/types/FormProps * @param {Component} [Tooltip] - React component to wrap inputs with tooltip * @returns {Function} decorator - HOC wrapper function for given React component */ export function withForm (options = {subscription: {pristine: true, valid: true}}, Tooltip = TooltipPop) { return function Decorator (Class) { // @Note: form field re-renders because of constantly changing formProps reference // => convert it to instance getter, so `asField` does not depend on formProps. // => cannot use context, because it triggers re-render of all child components. // Define withFormSetup here to load it only once on App init withFormSetup(Class, {fieldValues, registeredFieldValues, registeredFieldErrors, Tooltip}) // @Note: to avoid several WithForm instances sharing the same closure props, // this decorator must return a class component that stores its internal state between re-renders. // Use PureComponent to avoid double checking large payloads. // noinspection JSPotentiallyInvalidUsageOfThis return class WithForm extends PureComponent { get initValues () { return this._initValues || (this._initValues = this.props.initialValues) } // @see: https://final-form.org/docs/react-final-form/types/FormProps // Form only calls `render` function when `subscription` changes, or itself rerenders. // `formState` can remain unchanged, even if `initialValues` changed. // thus comparing formState is not suitable for memoizing when props change. // `formProps` does not pass through `initialValues` (it's undefined). // => better to let `render` function always run, and memoize at the highest <WithForm> level. // => this way, rerender is minimized to only when props changed, or form state changed. renderForm = ({form, handleSubmit, ...formProps}) => { // warn('-->>renderForm-----------------') // formProps `form` and `handleSubmit` props always change, possibly due to inline fat arrow function. this.form = form this.handleSubmit = handleSubmit if (!isEqualJSON(this._formProps, formProps)) this._formProps = formProps // Class should use PureComponent to take advantage of caching return <Class {...this._props} formProps={this._formProps} initialValues={this._initValues} instance={this}/> } UNSAFE_componentWillReceiveProps (next, nextContext) { const {initialValues} = next // Only assign `initialValues` when it truly changes if (this._initValues !== initialValues && !isEqualJSON(this._initValues, initialValues)) { this._initValues = initialValues // explicitly reset to new values when entries change, // because final-form only resets to the very first initialValues. if (this.form) this.form.reset(this._initValues) } } render () { // warn('-->>WithForm-------------------------------------------') const {initialValues, onSubmit = warn, ...restProps} = this.props this._props = restProps // @Note: when form is submitted, it triggers loading true, and receives old initialValues. // If the `initialValues` is computed on the fly and changes reference each time, // <Form/> reinitialises while loading, causing the flickering. // => either cache `initialValues`, or better, stop <Form/> from reinitializing while loading. // because final-form always re-initializes, there is no `enableReinitialize` like redux-form. return <Form onSubmit={onSubmit} {...options} initialValues={this.initValues} render={this.renderForm}/> } } } } /** * Mixin to add Class Attributes and Methods commonly used with forms * @note: works with react-final-form, redux-form requires update with parent `instance` for `this.form` to work * * @param {Object} Class - React Component or PureComponent to decorate * @param {Function} fieldValues - callback to get form values * @param {Function} registeredFieldValues - callback to get form registered values * @param {Function} registeredFieldErrors - callback to get form registered errors * @param {Component} [Tooltip] - React component to wrap inputs with tooltip * @returns {Object} Class - mutated with form properties */ export function withFormSetup (Class, {fieldValues, registeredFieldValues, registeredFieldErrors, Tooltip}) { if (!Active.renderField) throw new Error(`${withFormSetup.name} requires Active.renderField to be registered`) const UNSAFE_componentWillReceiveProps = Class.prototype.UNSAFE_componentWillReceiveProps const componentWillUnmount = Class.prototype.componentWillUnmount const handleChangeInput = Class.prototype.handleChangeInput Class.propTypes = { formProps: PropTypes.object.isRequired, // form props, without `form` and `handleSubmit` instance: PropTypes.object.isRequired, // {Class<form, handleSubmit>} WithForm instance for getting the form initialValues: PropTypes.object, // form initial values onChangeState: PropTypes.func, // onChangeState(this: Class) ...Class.propTypes } Class.prototype.state = { // This state only updates on input changes, for changes in parent props, use this.changedValues canSave: false, // used to compare changes for re-rendering, like 'Save' button ...Class.prototype.state } // Define instance getter Object.defineProperty(Class.prototype, 'form', { get () {return this.props.instance.form} }) // Define instance getter Object.defineProperty(Class.prototype, 'handleSubmit', { get () {return this.props.instance.handleSubmit} }) // Define instance getter Object.defineProperty(Class.prototype, 'canSave', { get () { // @note: do not use `pristine` because it only reflects visible (i.e. registered inputs) // do not use `valid` because it does not compute correctly on tab changes in FieldsInGroup const {loading} = this._props || this.props return !loading && !registeredFieldErrors(this.form) && !!this.changedValues } }) // Define instance getter Object.defineProperty(Class.prototype, 'formValues', { get () { return fieldValues(this.form) } }) // Define instance getter Object.defineProperty(Class.prototype, 'registeredValues', { get () { return registeredFieldValues(this.form) } }) // Define instance getter Object.defineProperty(Class.prototype, 'changedValues', { get () { // Have to select all form values, because registered values may not include all input values const {initialValues} = this._props || this.props return objChanges(initialValues, this.formValues) } }) // Define instance getter Object.defineProperty(Class.prototype, 'changedAndRegisteredValues', { get () { const values = Object.assign({}, this.registeredValues || {}, this.changedValues || {}) if (hasObjectValue(values)) return values } }) // Define instance getter Object.defineProperty(Class.prototype, 'validationErrors', { get () { const errors = registeredFieldErrors(this.form) if (!errors) return null const messages = [] // Use label if defined, for more intuitive error messages const fields = this._fields || [] for (const k in errors) { let {label, labelGroup} = fields.find(({name}) => name === k) || {} label = labelGroup || label || k messages.push(<Text key={k} className="margin-bottom-smaller">{`• ${label}: ${toJSON(errors[k])}`}</Text>) } return ( <View className="padding-h-smaller"> <Text className="margin-v-small bold">{_.PLEASE_COMPLETE_}</Text> {messages} </View> ) } }) Object.defineProperty(Class.prototype, 'validationErrorsTooltip', { get () { const errors = this.validationErrors return errors ? <ToolTip top>{errors}</ToolTip> : null } }) // Define instance method Class.prototype.renderInput = function (FIELDS, {onChange, fieldsSetup} = {}) { const {initialValues} = this.props this._fields = fieldsFrom(FIELDS, {initialValues}) if (fieldsSetup) this._fields = this._fields.map(fieldsSetup) return this._fields .map(({id, onRenderProps, ...field}, i) => ({ // convert id to key just before rendering, to prevent unmounts on form.reset() key: `${id}_${field.name || i}`, ...field, ...onRenderProps && onRenderProps(this, initialValues), onChange: (...args) => { field.onChange && field.onChange(...args, this) this.handleChangeInput(...args) onChange && onChange(...args) }, instance: this, })) .map(({tooltip, ...field}) => { const result = Active.renderField(field) // Wrap component with Tooltip automatically if (tooltip != null) return <Tooltip key={field.key} {...tooltipProps(tooltip)}>{result}</Tooltip> return result }) } // Define instance method Class.prototype.handleChangeInput = debounce(function () { // To handle use case when all fields in a group are removed, and no registered values are sent to backend, // use placeholder parent field that reserves as registered null value field for the entire group. // See <Fields> component for example. this.syncInputChanges() if (handleChangeInput) handleChangeInput.apply(this, arguments) }, UI.TYPING_DELAY) // Define instance method Class.prototype.syncInputChanges = function () { const canSave = this.canSave if (canSave !== this.state.canSave) { this.setState({canSave}) const {onChangeState} = this._props || this.props if (onChangeState) onChangeState(this) } } Class.prototype.UNSAFE_componentWillReceiveProps = function (next) { // @Note: using componentDidUpdate comparison logic is not reliable, // because on the last re-render, Form may trigger `pristine` update without changing initialValues, // which will make .canSave false, but this.syncInputChanges() only updated in the previous render, which was true. // => thus need to take formProps into consideration if ( !isEqualJSON(next.initialValues, this.props.initialValues) || !isEqualJSON(next.formProps, this.props.formProps) ) { // temporarily set to next props for state computation this._props = next this.syncInputChanges() this._props = null } if (UNSAFE_componentWillReceiveProps) UNSAFE_componentWillReceiveProps.apply(this, arguments) } Class.prototype.componentWillUnmount = function () { this.isUnmounting = true if (this.props.onChangeState) this.props.onChangeState({}) if (componentWillUnmount) componentWillUnmount.apply(this, arguments) } return Class } /** * React Component Group Input Change Decorator to fire onChange callback as group of fields together * * @usage: * - provides this.fields prop that is automatically hooked with `onChange` to fire callback as group of inputs * * @example: * @withGroupInputChange * class Fields extends Component { * render () { * return this.fields.map(renderField) * } * } * * @param {Object} constructor - class */ export function withGroupInputChange (constructor) { const componentDidMount = constructor.prototype.componentDidMount constructor.propTypes = { name: PropTypes.string, // top level field prefix items: PropTypes.array.isRequired, // list of fields attributes to render instance: PropTypes.object.isRequired, // Instance of the Class component decorated withFormSetup (i.e withForm) initialValues: PropTypes.object, // input default values, required for `onChange` callback to work properly onChange: PropTypes.func, // callback, receiving all nested field value changes combined, grouped by input name required: PropTypes.bool, // input prop disabled: PropTypes.bool, // input prop ...constructor.propTypes, } // Define instance getter Object.defineProperty(constructor.prototype, 'fields', { get () { // Hook to `onChange` call from each field in the group const {items, name: prefix, instance} = this.props return items.map(({name, onChange, ...field}) => ({ name: prefix ? (prefix + '.' + name) : name, onChange: (val, ...args) => { onChange && onChange(val, ...args) this.handleChangeInput(name, val) }, ...field, instance, })) } }) constructor.prototype.handleChangeInput = function (name, value) { const {onChange} = this.props if (!onChange) return this.values = {...this.values, [name]: value} onChange(this.values) } constructor.prototype.componentDidMount = function () { const {onChange, initialValues, name} = this.props if (name && onChange && initialValues === undefined) warn(this.constructor.name, `.${name} requires 'initialValues', if 'onChange(values)' is used`) // Oly pre-populate group values if initialValues was subset of the entire form, so changes can be submitted together if (initialValues && name) this.values = {...initialValues} if (componentDidMount) componentDidMount.apply(this, arguments) } return constructor } /** * Compose Validators Array into a Single Validator Function * @param {Function[]|function(any)} validators - to compose * @returns {Function} validator for use with react-final-form */ export function composeValidators (...validators) { return (value) => validators.reduce((error, validator) => error || validator(value), undefined) }