3
votes

I've got a form with some text inputs, and some custom components. I've got the Formik validation working on the text inputs but not the custom components. I am now trying to add Formik validation to my custom categoriesMultiselect component. This component holds its data in the redux store. I have handled the validation myself and added a value to my redux props:

const mapStateToProps = (
  state: RecordOf<VepoState>,
  ownProps: { rerenderKey: boolean }
) => ({...
    isCategoriesValid: selectIsCategoriesValid(state),
...
})

. I just want to plug props.isCategoriesValid into my Formik form.

From reading the official documentation, I think I do that by adding validate={validateCategories} as a prop to the custom component and adding the function validateCategories to the component. So I have tried that like this:

//above React render()
      validateCategories = () => {
        let error
        if (!this.props.selectIsCategoriesValid) {
          error = 'please select a category'
        }
        return error
      }

// Inside React render()
          <CategoriesMultiselect.View
            validate={this.validateCategories}
            name={'categories'}
            label={'Product Categories'}
            categoryCodes={[CategoryEnums.CategoryCodes.Grocery]}
          />

validateCategories never gets run. That's why I have tested running it by adding validateField to one of my inputs onChange function:

      <Input
        label={'Product Brand'}
        value={values.brand}
        onTouch={setFieldTouched}
        error={touched.brand && errors.brand}
        placeholder="Enter Brand"
        name="brand"
        required
        onChange={() => validateField('categories')}
        deleteText={setFieldValue}
      />

When it tries to validate the field, I get console error:

Cannot read property 'validate' of undefined

on this line of code in Formik:

  var validateField = useEventCallback(function (name) {
if (isFunction(fieldRegistry.current[name].validate)) {

I have at least plugged Formik into my Redux because I am at least successfully dispatching a Redux action when submitting the form. What am I doing wrong?

Code:

//@flow

import * as Yup from 'yup'
import { Formik, withFormik } from 'formik'
import { Container } from 'native-base'
import * as React from 'react'
import { ScrollView, View, Alert, Button } from 'react-native'
import { connect } from 'react-redux'
import { Category as CategoryEnums } from 'src/enums'
import type { VepoState } from 'src/components/model'
import type { RecordOf } from 'immutable'
import type { Product } from 'src/model'
import VepoHeader from 'src/components/formControls/header/view'
import { selectIsAddFormValid } from './selector'
import { selectProduct } from './selector'
// import { Button } from 'src/components/formControls'
import { ImagePicker } from 'src/components/formControls'
import LocationAutocomplete from 'src/components/formControls/locationAutocomplete/view'
import { uploadAddProduct, updateRerenderKey } from './action'
import { viewStyle } from './style'
import type { Dispatch } from 'redux'
import { updateAddProductImage } from './action'
import type { Place } from 'src/model/location'
import { Colors, Spacing } from 'src/styles'
import { Input } from 'src/components/formControls'
import { onPress } from './controller'
import { CategoriesMultiselect } from 'src/components/formControls'
import {
  selectIsGrocerySelected,
  selectIsCategoriesValid,
  isLocationValid
} from 'src/components/product/add/groceryItem/selector'

const mapStateToProps = (
  state: RecordOf<VepoState>,
  ownProps: { rerenderKey: boolean }
) => ({
  locationListDisplayed: state.formControls.root.locationListDisplayed,
  isAddFormValid: selectIsAddFormValid(state),
  // $FlowFixMe
  product: selectProduct(state),
  // $FlowFixMe
  isGrocerySelected: selectIsGrocerySelected(state),
  // $FlowFixMe
  categories: state.formControls.categories,
  isCategoriesValid: selectIsCategoriesValid(state),
  image: state.product.add.image,
  rerenderKey: ownProps.rerenderKey,
  location: state.formControls.location,
  isLocationValid: isLocationValid(state)
})

// eslint-disable-next-line flowtype/no-weak-types
const mapDispatchToProps = (dispatch: Dispatch<*>): Object => ({
  updateAddProductImage: (value): void => {
    dispatch(updateAddProductImage({ value }))
  },
  uploadAddProduct: (product: Product): void => {
    dispatch(uploadAddProduct(product))
  },
  updateRerenderKey: () => {
    dispatch(updateRerenderKey())
  }
})

export const getLocationIsValid = (place: Place): boolean => {
  return Object.keys(place).length > 0 ? true : false
}
type AddGroceryStoreState = {
  name: string,
  brand: string,
  description: string,
  price?: number
}

class AddGroceryItemView extends React.Component<any, AddGroceryStoreState> {
  validateCategories = () => {
    let error
    if (!this.props.selectIsCategoriesValid) {
      error = 'please select a category'
    }
    return error
  }
  render() {
    const {
      values,
      handleSubmit,
      setFieldValue,
      errors,
      touched,
      setFieldTouched,
      isValid,
      isSubmitting,
      validateField
    } = this.props
    return (
      <Container>
        <VepoHeader title={'Add Vegan Grocery Product'} />
        <Container style={container}>
          <ScrollView
            keyboardShouldPersistTaps="always"
            style={viewStyle(this.props.locationListDisplayed).scrollView}>
            <View>
              <LocationAutocomplete
                label={'Grocery Store'}
                placeHolder={'Enter Grocery Store'}
              />
            </View>
            <View style={viewStyle().detailsContainer}>
              <ImagePicker
                label={'Product Image (optional)'}
                image={this.props.image.image}
                updateAddProductImage={this.props.updateAddProductImage}
                updateRerenderKey={this.props.updateRerenderKey}
              />
              <Input
                label={'Product Name'}
                onTouch={setFieldTouched}
                value={values.name}
                placeholder="Enter Name"
                name="name"
                required
                error={touched.name && errors.name}
                deleteText={setFieldValue}
                onChange={setFieldValue}
              />
              <Input
                label={'Product Brand'}
                value={values.brand}
                onTouch={setFieldTouched}
                error={touched.brand && errors.brand}
                placeholder="Enter Brand"
                name="brand"
                required
                onChange={() => validateField('categories')}
                deleteText={setFieldValue}
              />
              <View>
                <Input
                  label={'Product Description'}
                  value={values.description}
                  placeholder="Enter Description"
                  multiline={true}
                  required
                  onTouch={setFieldTouched}
                  error={touched.description && errors.description}
                  numberOfLines={4}
                  name="description"
                  deleteText={setFieldValue}
                  onChange={setFieldValue}
                />
                <Input
                  isValid={true}
                  isPrice={true}
                  label={'Product Price'}
                  value={values.price}
                  onTouch={setFieldTouched}
                  error={touched.price && errors.price}
                  placeholder="Enter Price"
                  name="price"
                  deleteText={setFieldValue}
                  onChange={setFieldValue}
                />
                <View>
                  <CategoriesMultiselect.View
                    validate={this.validateCategories}
                    name={'categories'}
                    label={'Product Categories'}
                    categoryCodes={[CategoryEnums.CategoryCodes.Grocery]}
                  />
                </View>
              </View>
            </View>
          </ScrollView>
        </Container>
        <Button
          title="submit"
          onPress={handleSubmit}
          disabled={!isValid || isSubmitting}
          loading={isSubmitting}
        />
        {/* <Button.View onSub={this._handleSubmit} onPress={this._handleSubmit} label={'GO!'} /> */}
      </Container>
    )
  }
}

const container = {
  flex: 1,
  ...Spacing.horizontalPaddingLarge,
  backgroundColor: Colors.greyLight,
  flexDirection: 'column'
}

const formikEnhancer = withFormik({
  validationSchema: Yup.object().shape({
    name: Yup.string().required(),
    brand: Yup.string().required(),
    categories: Yup.array(),
    description: Yup.string()
      .min(9)
      .required(),
    price: Yup.number()
      .typeError('price must be a number')
      .required()
  }),
  mapPropsToValues: () => ({
    name: '',
    brand: '',
    description: '',
    price: '',
    categories: []
  }),
  handleSubmit: (values, { props }) => {
    props.updateRerenderKey()
  },
  displayName: 'AddGroceryItemView'
})(AddGroceryItemView)

// $FlowFixMe
const AddGroceryItemViewComponent = connect(
  mapStateToProps,
  mapDispatchToProps
)(formikEnhancer)

export default AddGroceryItemViewComponent

As Rikin requested, here is the CategoriesMultiselect component:

//@flow
import type { Node } from 'react'
import { selectSelectedCategory } from 'src/components/product/add/groceryItem/selector'
import type { VepoState } from 'src/components/model'
import type { RecordOf } from 'immutable'
import { connect } from 'react-redux'
import * as React from 'react'
import { View } from 'react-native'
import {
  List,
  ListItem,
  Text,
  Left,
  Body,
  Right,
  Button,
  Container,
  Label,
  Title,
  Content
} from 'native-base'
import Icon from 'react-native-vector-icons/FontAwesome'
import Eicon from 'react-native-vector-icons/EvilIcons'
import Modal from 'react-native-modal'
import SelectMultiple from 'react-native-select-multiple'
import {
  updateAlertModalIsOpen,
  updateAlertModalHasYesNo,
  updateAlertModalMessage,
  updateAlertModalTitle
} from 'src/components/formControls/alertModal/action'
import * as C from './model'
import type { Subcategory } from 'src/model/category'

import * as controller from './controller'
import { getIsCategoriesValid } from './controller'
import { styles } from 'src/components/style'
import {
  Colors,
  Corners,
  Distances,
  Modals,
  Spacing,
  Typography,
  ZIndexes
} from 'src/styles'
import { Containers } from '../../../styles'
import {
  toggleSubcategory,
  setAllShowSubcategoriesToFalse,
  toggleShowSubcategories
} from './action'
import type { Dispatch } from 'redux'

const mapStateToProps = (state: RecordOf<VepoState>) => ({
  vepo: state,
  // $FlowFixMe
  selectedCategory: selectSelectedCategory(state),
  categories: state.formControls.categories
})

// eslint-disable-next-line flowtype/no-weak-types
const mapDispatchToProps = (dispatch: Dispatch<*>): Object => ({
  setAllShowSubcategoriesToFalse: (): void => {
    dispatch(setAllShowSubcategoriesToFalse())
  },
  toggleSubcategory: (sc): void => {
    return dispatch(toggleSubcategory(sc))
  },
  toggleShowSubcategories: (c): void => {
    dispatch(toggleShowSubcategories(c))
  },
  updateAlertModalIsOpen: (isOpen: boolean): void => {
    dispatch(updateAlertModalIsOpen(isOpen))
  },
  updateAlertModalMessage: (message: string): void => {
    dispatch(updateAlertModalMessage(message))
  },
  updateAlertModalHasYesNo: (hasYesNo: boolean): void => {
    dispatch(updateAlertModalHasYesNo(hasYesNo))
  },
  updateAlertModalTitle: (title: string): void => {
    dispatch(updateAlertModalTitle(title))
  }
})

const renderCategoryRow = (props: C.CategoriesViewProps, item: C.Category) => {
  return (
    <View>
      <ListItem
        style={listItem}
        icon
        onPress={() => controller.categoryClicked(props, item)}>
        <Left>
          <Icon
            style={styles.icon}
            name={item.icon}
            size={20}
            color={item.iconColor}
          />
        </Left>
        <Body style={[styles.formElementHeight, border(item)]}>
          <Text style={Typography.brownLabel}>{item.label}</Text>
        </Body>
        <Right style={[styles.formElementHeight, border(item)]}>
          <Eicon style={catStyle.arrow} name="chevron-right" size={30} />
        </Right>
      </ListItem>
    </View>
  )
}
const getCategoriesToDisplay = (props) => {
  const y = props.categories.filter((x) => props.categoryCodes.includes(x.code))
  return y
}

class CategoriesMultiselectView extends React.Component {
  setFormCategories = () => {
    if (this.props && this.props.setFieldValue) {
      this.props.setFieldValue(
        'categories',
        controller.getSelectedSubcategories(this.props.categories)
      )
    }
  }

  render(): React.Node {
    const categoriesToDisplay = getCategoriesToDisplay(this.props)
    return (
      <View>
        <View style={{ ...Containers.fullWidthRow }}>
          <Label disabled={false} style={Typography.formLabel}>
            {this.props.label}
          </Label>
          <View style={{ ...Containers.fullWidthRow }} />
          <Label disabled={false} style={Typography.formLabel}>
            {controller.getNumberOfSelectedSubcategories(this.props.categories)}{' '}
            Selected
          </Label>
        </View>
        <View
          style={catStyle.categoriesViewStyle(this.props, categoriesToDisplay)}>
          {this.props.categories && this.props.categories.length > 0 && (
            <List
              listBorderColor={'white'}
              style={categoriesListStyle}
              dataArray={categoriesToDisplay}
              renderRow={(item: C.Category) => {
                return renderCategoryRow(this.props, item)
              }}
            />
          )}
          <View style={catStyle.modalConatinerStyle} />
          <Modal
            style={catStyle.modal}
            onModalHide={this.setFormCategories}
            isVisible={
              this.props.categories
                ? this.props.categories.some((cat: C.Category) =>
                    controller.showModal(cat)
                  )
                : false
            }>
            <Container style={catStyle.modalView}>
              <View style={Modals.modalHeader}>
                <Title style={catStyle.categoriesTitleStyle}>
                  {controller.getDisplayedCategoryLabel(this.props.categories)}
                </Title>
                <Right>
                  <Button
                    transparent
                    icon
                    onPress={this.props.setAllShowSubcategoriesToFalse}>
                    <Eicon name="close-o" size={25} color="#FFFFFF" />
                  </Button>
                </Right>
              </View>
              <Content style={catStyle.categoryStyle.modalContent}>
                <SelectMultiple
                  checkboxSource={require('../../../images/unchecked.png')}
                  selectedCheckboxSource={require('../../../images/checked.png')}
                  labelStyle={[
                    styles.label,
                    styles.formElementHeight,
                    styles.modalListItem
                  ]}
                  items={controller.getDisplayedSubcategories(
                    this.props.categories
                  )}
                  selectedItems={controller.getSelectedSubcategories(
                    this.props.categories
                  )}
                  onSelectionsChange={(selections, item: Subcategory) => {
                    this.props.toggleSubcategory({ subcategory: item }).the
                  }}
                />
              </Content>
            </Container>
          </Modal>
        </View>
        {this.props.error && (
          <Label
            disabled={false}
            style={[
              Typography.formLabel,
              { color: 'red' },
              { marginBottom: Spacing.medium }
            ]}>
            {this.props.error}
          </Label>
        )}
      </View>
    )
  }
}

const catStyle = {
  // eslint-disable-next-line no-undef
  getBorderBottomWidth: (item: C.Category): number => {
    if (item.icon === 'shopping-basket') {
      return Spacing.none
    }
    return Spacing.none
  },
  // eslint-disable-next-line no-undef
  categoriesViewStyle: (props: C.CategoriesViewProps, categoriesToDisplay) => {
    return {
      backgroundColor: Colors.borderLeftColor(
        getIsCategoriesValid(props.categories)
      ),
      ...Corners.rounded,
      paddingLeft: Spacing.medium,
      height: Distances.FormElementHeights.Medium * categoriesToDisplay.length,
      overflow: 'hidden',
      borderBottomWidth: Spacing.none
    }
  },
  arrow: {
    color: Colors.brownDark,
    borderBottomColor: Colors.brownDark
  },
  icon: { height: Distances.FormElementHeights.Medium },
  // eslint-disable-next-line no-undef
  categoriesTitleStyle: {
    ...styles.title,
    ...Typography.titleLeftAlign
  },
  categoryStyle: {
    modalContent: {
      ...Corners.rounded
    }
  },
  modal: {
    flex: 0.7,
    height: 20,
    marginTop: Spacing.auto,
    marginBottom: Spacing.auto
  },
  modalView: {
    backgroundColor: Colors.white,
    height: 500,
    ...Corners.rounded
  },
  modalConatinerStyle: {
    marginBottom: Spacing.medium,
    color: Colors.brownDark,
    backgroundColor: Colors.brownLight,
    position: 'absolute',
    zIndex: ZIndexes.Layers.Negative,
    right: Spacing.none,
    height: Distances.Distances.Full,
    width: Distances.Distances.Full,
    ...Corners.rounded
  }
}

const categoriesListStyle = {
  flex: Distances.FlexDistances.Full,
  color: Colors.brownDark,
  backgroundColor: Colors.brownLight,
  height: Distances.FormElementHeights.Double,
  ...Corners.notRounded,
  marginRight: Spacing.medium
}

const border = (item: C.Category) => {
  return {
    borderBottomWidth: catStyle.getBorderBottomWidth(item),
    borderBottomColor: Colors.brownMedium
  }
}

const listItem = {
  height: Distances.FormElementHeights.Medium
}

// $FlowFixMe
const CategoriesMultiselect = connect(
  mapStateToProps,
  mapDispatchToProps
)(CategoriesMultiselectView)

export default CategoriesMultiselect
1
How is CategoriesMultiselect coded? I see you are using field level validation, alternatively you can also use form level validation example of which I'm posting in answer below while you revert back on CategoriesMultiselect codeRikin

1 Answers

1
votes

Example of form level validation usage by directly setting property in the form categories with error message.

...
...
...

const validateCategories = (values, props) => {
    let error = {}
    if (!props.selectIsCategoriesValid) {
      error.categories = 'please select a category'
    }
    return error
  }

class AddGroceryItemView extends React.Component<any, AddGroceryStoreState> {

  render() {
    const { ... } = this.props
    return (
      <Container>
        <VepoHeader title={'Add Vegan Grocery Product'} />
        <Container style={container}>
          <ScrollView
            keyboardShouldPersistTaps="always"
            style={viewStyle(this.props.locationListDisplayed).scrollView}>
            <View>
              ...
            </View>
            <View style={viewStyle().detailsContainer}>
              ...
              <View>
                ...
                <View>
                  <CategoriesMultiselect.View
                    // validate={this.validateCategories}
                    name={'categories'}
                    label={'Product Categories'}
                    categoryCodes={[CategoryEnums.CategoryCodes.Grocery]}
                  />
                </View>
              </View>
            </View>
          </ScrollView>
        </Container>
        ...
      </Container>
    )
  }
}

...

const formikEnhancer = withFormik({
  validationSchema: Yup.object().shape({
    ...
  }),
  mapPropsToValues: () => ({
    ...
  }),
  handleSubmit: (values, { props }) => {
    ...
  },
  displayName: 'AddGroceryItemView',
  validate: validateCategories
})(AddGroceryItemView)

// $FlowFixMe
const AddGroceryItemViewComponent = connect(
  mapStateToProps,
  mapDispatchToProps
)(formikEnhancer)

export default AddGroceryItemViewComponent

Updated Field level validation using Formik's Field:

However personally I would go with Form level validation as first line of defense you are relying on validationSchema which should take care of field level validation at first and then second line of defense you should go with form-level where you can place custom messaging after validationSchema passes the test. If you put in field level then you may end up in possibly rabbit hole where it may lead to difficulty in maintaining components and customize it for individual scenarios in your app. To me form level validation is explicit enough at one convenient place for all additional field level validation. Alternatively you can also put in all your validationSchema and form-level validation function in a single file and then import it in your main component where you are going to wrap withFormik HOC.

Either way its upto you on how you want based on your requirements.

Here's the official docs link: https://jaredpalmer.com/formik/docs/guides/validation#field-level-validation

And as per that:

Note: The / components' validate function will only be executed on mounted fields. That is to say, if any of your fields unmount during the flow of your form (e.g. Material-UI's unmounts the previous your user was on), those fields will not be validated during form validation/submission.

//@flow
...
import SelectMultiple from 'react-native-select-multiple'
...
import {
  toggleSubcategory,
  setAllShowSubcategoriesToFalse,
  toggleShowSubcategories
} from './action'
...
import { Field } from 'formik'

...

class CategoriesMultiselectView extends React.Component {
  setFormCategories = () => {
    if (this.props && this.props.setFieldValue) {
      this.props.setFieldValue(
        'categories',
        controller.getSelectedSubcategories(this.props.categories)
      )
    }
  }

  render(): React.Node {
    const categoriesToDisplay = getCategoriesToDisplay(this.props)
    return (
      <View>
        <View style={{ ...Containers.fullWidthRow }}>
          ...
        </View>
        <View
          style={catStyle.categoriesViewStyle(this.props, categoriesToDisplay)}>
          {...}
          <View style={catStyle.modalConatinerStyle} />
          <Modal
            style={catStyle.modal}
            onModalHide={this.setFormCategories}
            isVisible={
              this.props.categories
                ? this.props.categories.some((cat: C.Category) =>
                    controller.showModal(cat)
                  )
                : false
            }>
            <Container style={catStyle.modalView}>
              <View style={Modals.modalHeader}>
                ...
              </View>
              <Content style={catStyle.categoryStyle.modalContent}>
                <Field name="categories" validate={validate_Function_HERE_which_can_be_via_props_or_locally_defined} render={({field, form}) =>
                  <SelectMultiple
                    checkboxSource={require('../../../images/unchecked.png')}
                    selectedCheckboxSource={require('../../../images/checked.png')}
                    labelStyle={[
                      styles.label,
                      styles.formElementHeight,
                      styles.modalListItem
                    ]}
                    items={controller.getDisplayedSubcategories(
                      this.props.categories
                    )}
                    selectedItems={controller.getSelectedSubcategories(
                      this.props.categories
                    )}
                    onSelectionsChange={(selections, item: Subcategory) => {
                      this.props.toggleSubcategory({ subcategory: item }).the
                    }}
                  />}
                />
              </Content>
            </Container>
          </Modal>
        </View>
        {this.props.error && (
          <Label
            disabled={false}
            style={[
              Typography.formLabel,
              { color: 'red' },
              { marginBottom: Spacing.medium }
            ]}>
            {this.props.error}
          </Label>
        )}
      </View>
    )
  }
}

...

// $FlowFixMe
const CategoriesMultiselect = connect(
  mapStateToProps,
  mapDispatchToProps
)(CategoriesMultiselectView)

export default CategoriesMultiselect