Валидация сложных форм React. Часть 1

Для начала надо установить компонент react-validation-boo, предполагаю что с react вы знакомы и как настроить знаете.

npm install react-validation-boo

Чтобы много не болтать, сразу приведу небольшой пример кода.

import React, {Component} from 'react';
import {connect, Form, Input, logger} from 'react-validation-boo';

class MyForm extends Component {
    sendForm = (event) => {
        event.preventDefault();

        if(this.props.vBoo.isValid()) {
            console.log('Получаем введённые значения и отправляем их на сервер', this.props.vBoo.getValues());
        } else {
            console.log('Выведем в консоль ошибки', this.props.vBoo.getErrors());
        }
    };
    getError = (name) => {
        return this.props.vBoo.hasError(name) ? <div className="error">{this.props.vBoo.getError(name)}</div> : '';
    };
    render() {
        return <Form connect={this.props.vBoo.connect}>
            <div>
                <Input type="text" name="name" />
                {this.getError('name')}
            </div>
            
            <button onClick={this.sendForm}>
                {this.props.vBoo.isValid() ? 'Можно отправлять': 'Будьте внимательны!!!'}
            </button>
        </Form>
    }
}

export default connect({
    rules: () => (
        [
            ['name', 'required'],
        ]
    ),
    middleware: logger
})(MyForm);


Давайте разберём этот код.

Начнём с функции connect, в него мы передаём наши правила валидации и другие дополнительные параметры. Вызвав этот метод мы получаем новую функцию в которую передаём наш компонент(MyForm), чтобы он получил в props необходимые методы работы с валидацией форм.

В функции render нашего компонента мы возвращаем компонент Form который соединяем с правилами валидации connect={this.props.connect}. Эта необходимая конструкция для того чтобы Form знал как валидировать вложенные компоненты.
<Input type=«text» name=«name» /> поле ввода которое мы будем проверять, правила проверки мы передали connect в свойстве rules. В нашем случае это name не должно быть пустым(required).

Также мы в connect передали middleware: logger, для того чтобы в консоли увидеть как происходит валидация.

В props нашего компонента мы получили набор функций:

  1. vBoo.isValid() — возвращает true, если все компоненты ввода прошли валидацию
  2. vBoo.hasError(name) — возвращает true, если компонент со свойством name не валидин
  3. vBoo.getError(name) — для компонента со свойством name, возвращает текст ошибки

Теперь постепенно будем усложнять, для начала в connect передадим язык, для того чтобы можно было менять правила валидации в зависимости от языка, а также добавим дополнительные поля и правила валидации.

import React, {Component} from 'react';
import {connect, Form, Input, InputCheckbox} from 'react-validation-boo';

class MyForm extends Component {
    sendForm = (event) => {
        event.preventDefault();

        if(this.props.vBoo.isValid()) {
            console.log('Получаем введённые значения и отправляем их на сервер', this.props.vBoo.getValues());
        } else {
            console.log('Выведем в консоль ошибки', this.props.vBoo.getErrors());
        }
    };
    getError = (name) => {
        return this.props.vBoo.hasError(name) ? <div className="error">{this.props.vBoo.getError(name)}</div> : '';
    };
    render() {
        return <Form connect={this.props.vBoo.connect}>
            <div>
                <label>{this.props.vBoo.getLabel('name')}:</label>
                <Input type="text" name="name" />
                {this.getError('name')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('email')}:</label>
                <Input type="text" name="email" value="default@mail.ru" />
                {this.getError('email')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('remember')}:</label>
                <InputCheckbox name="remember" value="yes" />
                {this.getError('remember')}
            </div>
            
            <button onClick={this.sendForm}>
                {this.props.vBoo.isValid() ? 'Можно отправлять': 'Будьте внимательны!!!'}
            </button>
        </Form>
    }
}

export default connect({
    rules: (lang) => {
        let rules =  [
            [
                ['name', 'email'],
                'required',
                {
                    error: '%name% не должно быть пустым'
                }
            ],
            ['email', 'email']
        ];
        
        rules.push(['remember', lang === 'ru' ? 'required': 'valid']);
        return rules;
    },
    labels: (lang) => ({
        name: 'Имя',
        email: 'Электронная почта',
        remember: 'Запомнить'
    }),
    lang: 'ru'
})(MyForm);

В данном примере чекбокс remember на русском язык обязательно должен быть установлен required, а на других он всегда валиден valid.

Также мы передали в connect функцию labels(lang), которая возвращает название полей в читаемом виде.

В props вашего компонента, есть функция getLabel(name), которая возвращает значение переданное функцией labels или если такого значения нет, то возвращает name.

Базовые компоненты vBoo


Form, Input, InputRadio, InputCheckbox, Select, Textarea.

import React, {Component} from 'react';
import {connect, Form, Input, Select, InputRadio, InputCheckbox, Textarea} from 'react-validation-boo';

class MyForm extends Component {
    sendForm = (event) => {
        event.preventDefault();

        if(this.props.vBoo.isValid()) {
            console.log('Получаем введённые значения и отправляем их на сервер', this.props.vBoo.getValues());
        } else {
            console.log('Выведем в консоль ошибки', this.props.vBoo.getErrors());
        }
    };
    getError = (name) => {
        return this.props.vBoo.hasError(name) ? <div className="error">{this.props.vBoo.getError(name)}</div> : '';
    };
    render() {
        return <Form connect={this.props.vBoo.connect}>
            <div>
                <label>{this.props.vBoo.getLabel('name')}:</label>
                <Input type="text" name="name" />
                {this.getError('name')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('email')}:</label>
                <Input type="text" name="email" value="default@mail.ru" />
                {this.getError('email')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('gender')}:</label>
                <Select name="gender">
                    <option disabled>Ваш пол</option>
                    <option value="1">Мужской</option>
                    <option value="2">Женский</option>
                </Select>
                {this.getError('gender')}
            </div>
            <div>
                <div>{this.props.vBoo.getLabel('familyStatus')}:</div>
                <div>
                    <InputRadio name="familyStatus" value="1" checked />
                    <label>холост</label>
                </div>
                <div>
                    <InputRadio name="familyStatus" value="2" />
                    <label>сожительство</label>
                </div>
                <div>
                    <InputRadio name="familyStatus" value="3" />
                    <label>брак</label>
                </div>
                {this.getError('familyStatus')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('comment')}:</label>
                <Textarea name="comment"></Textarea>
                {this.getError('comment')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('remember')}:</label>
                <InputCheckbox name="remember" value="yes" />
                {this.getError('remember')}
            </div>
            
            <button onClick={this.sendForm}>
                {this.props.vBoo.isValid() ? 'Можно отправлять': 'Будьте внимательны!!!'}
            </button>
        </Form>
    }
}

export default connect({
    rules: () => ([
        [
            ['name', 'email'],
            'required',
            {
                error: '%name% не должно быть пустым'
            }
        ],
        ['email', 'email'],
        [['gender', 'familyStatus', 'comment', 'remember'], 'valid']
    ]),
    labels: () => ({
        name: 'Имя',
        email: 'Электронная почта',
        gender: 'Пол',
        familyStatus: 'Семейное положение',
        comment: 'Комментарий',
        remember: 'Запомнить'
    }),
    lang: 'ru'
})(MyForm);

Правила валидации


Давайте рассмотрим как писать свои правила валидации.
Для того чтобы написать правило необходимо создать класс, который будет унаследован от класса validator.

import {validator} from 'react-validation-boo';

class myValidator extends validator {
    /**
    * name - имя поля, если есть label то передастся он
    * value - текущее значение поля
    * params - параметры которые были переданны 3-м агрументом в правила валидации(rules)
    */
    validate(name, value, params) {
        let lang = this.getLang();
        let pattern = /^\d+$/;
        
        if(!pattern.test(value)) {
            let error = params.error || 'Ошибка для поля %name% со значением %value%';
            error = error.replace('%name%', name);
            error = error.replace('%value%', value);
            this.addError(error);
        }
    }
}

export default myValidator;

Теперь подключим наш валидатор к форме.
import myValidator from 'path/myValidator';

// ...

export default connect({
    rules: () => ([
        [
            'name',
            'required',
            {
                error: '%name% не должно быть пустым'
            }
        ],
        [
            'name',
            'myValidator',
            {
                error: 'это и будет params.error'
            }
        ]
    ]),
    labels: () => ({
        name: 'Имя'
    }),
    validators: {
        myValidator
    },
    lang: 'ru'
})(MyForm);

Чтобы каждый раз не прописывать все ваши правила валидации, создаём отдельный файл, где они будут прописаны и подключаем его validators: `import 'file-validation'`. А если для этой формы есть какие-то особые правила, то validators: Object.assign({}, `import 'file-validation'`, {...})

Сценарии


Рассмотрим случаи когда нам необходимо менять правила валидации в зависимости от действий производимых на форме.

По умолчанию у нас сценарий, который называется default, в правилах мы можем прописать при каком сценарии проводить данную валидацию.

Если сценарий не указан, то валидация будет выполняться для всех сценариев.

rules = () => ([
    [
        'name',
        'required',
        {
            error: '%name% не должно быть пустым'
        }
    ],
    [
        'name',
        'myValidator',
        {
            scenario: ['default', 'scenario1']
        }
    ],
    [
        'email',
        'email',
        {
            scenario: 'scenario1'
        }
    ]
])

Через свойство props нашего компонента передаются функции:

  1. vBoo.setScenario(scenario) — устанавливает сценарий scenario, может быть как строка так и массив, если у нас активны сразу несколько сценариев
  2. vBoo.getScenario() — возвращает текущий сценарий или массив сценариев
  3. vBoo.hasScenario(name) — показывает установлен ли сейчас данный сценарий, name строка

Давайте в нашей форме добавим объект scenaries, в котором будем хранить все возможные сценарии, true сценарий активен, false нет.

А также функции addScenaries и deleteScenaries, которые будут добавлять и удалять сценарии.

Если у нас «семейное положение» выбрано «сожительство» или «брак», то добавляем поле комментарий и естественно это поле надо валидировать только в этом случае, сценарий 'scenario-married'.

Если у нас чекбокс «Дополнительно» выставлен, то добавляем дополнительные поля, которые станут обязательны для заполнения, сценарий 'scenario-addition'.

import React, {Component} from 'react';
import {connect, Form, Input, Select, InputRadio, InputCheckbox, Textarea} from 'react-validation-boo';

class MyForm extends Component {
    constructor() {
        super();

        this.scenaries = {
            'scenario-married': false,
            'scenario-addition': false
        }
    }
    changeScenaries(addScenaries = [], deleteScenaries = []) {
        addScenaries.forEach(item => this.scenaries[item] = true);
        deleteScenaries.forEach(item => this.scenaries[item] = false);

        let scenario = Object.keys(this.scenaries)
            .reduce((result, item) => this.scenaries[item]? result.concat(item): result, []);

        this.props.vBoo.setScenario(scenario);
    }
    addScenaries = (m = []) => this.changeScenaries(m, []);
    deleteScenaries = (m = []) => this.changeScenaries([], m);
    sendForm = (event) => {
        event.preventDefault();

        if(this.props.vBoo.isValid()) {
            console.log('Получаем введённые значения и отправляем их на сервер', this.props.vBoo.getValues());
        } else {
            console.log('Выведем в консоль ошибки', this.props.vBoo.getErrors());
        }
    };
    getError = (name) => {
        return this.props.vBoo.hasError(name) ? <div className="error">{this.props.vBoo.getError(name)}</div> : '';
    };
    changeFamilyStatus = (event) => {
        let val = event.target.value;
        if(val !== '1') {
            this.addScenaries(['scenario-married'])
        } else {
            this.deleteScenaries(['scenario-married']);
        }
    };
    changeAddition = (event) => {
        let check = event.target.checked;
        if(check) {
            this.addScenaries(['scenario-addition'])
        } else {
            this.deleteScenaries(['scenario-addition']);
        }
    };
    getCommentContent() {
        if(this.props.vBoo.hasScenario('scenario-married')) {
            return (
                <div key="comment-content">
                    <label>{this.props.vBoo.getLabel('comment')}:</label>
                    <Textarea name="comment"></Textarea>
                    {this.getError('comment')}
                </div>
            );
        }

        return '';
    }
    getAdditionContent() {
        if(this.props.vBoo.hasScenario('scenario-addition')) {
            return (
                <div key="addition-content">
                    <label>{this.props.vBoo.getLabel('place')}:</label>
                    <Input type="text" name="place" />
                    {this.getError('place')}
                </div>
            );
        }

        return '';
    }
    render() {
        return <Form connect={this.props.vBoo.connect}>
            <div>
                <label>{this.props.vBoo.getLabel('name')}:</label>
                <Input type="text" name="name" />
                {this.getError('name')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('email')}:</label>
                <Input type="text" name="email" value="default@mail.ru" />
                {this.getError('email')}
            </div>
            <div>
                <label>{this.props.vBoo.getLabel('gender')}:</label>
                <Select name="gender">
                    <option disabled>Ваш пол</option>
                    <option value="1">Мужской</option>
                    <option value="2">Женский</option>
                </Select>
                {this.getError('gender')}
            </div>
            <div>
                <div>{this.props.vBoo.getLabel('familyStatus')}:</div>
                <div>
                    <InputRadio name="familyStatus" value="1" checked onChange={this.changeFamilyStatus} />
                    <label>холост</label>
                </div>
                <div>
                    <InputRadio name="familyStatus" value="2" onChange={this.changeFamilyStatus} />
                    <label>сожительство</label>
                </div>
                <div>
                    <InputRadio name="familyStatus" value="3" onChange={this.changeFamilyStatus} />
                    <label>брак</label>
                </div>
                {this.getError('familyStatus')}
            </div>
            {this.getCommentContent()}
            <div>
                <label>{this.props.vBoo.getLabel('addition')}:</label>
                <InputCheckbox name="addition" value="yes" onChange={this.changeAddition} />
                {this.getError('addition')}
            </div>
            {this.getAdditionContent()}

            <button onClick={this.sendForm}>
                {this.props.vBoo.isValid() ? 'Можно отправлять': 'Будьте внимательны!!!'}
            </button>
        </Form>
    }
}

export default connect({
    rules: () => ([
        [
            ['name', 'gender', 'familyStatus', 'email'],
            'required',
            {
                error: '%name% не должно быть пустым'
            }
        ],
        ['email', 'email'],
        [
            'comment',
            'required',
            {
                scenario: 'scenario-married'
            }
        ],
        ['addition', 'valid'],
        [
            'place',
            'required',
            {
                scenario: 'scenario-addition'
            }
        ],
    ]),
    labels: () => ({
        name: 'Имя',
        email: 'Электронная почта',
        gender: 'Пол',
        familyStatus: 'Семейное положение',
        comment: 'Комментарий',
        addition: 'Дополнительно',
        place: 'Место'
    }),
    lang: 'ru'
})(MyForm);

Чтобы не делать статью очень большой, продолжу в следующей, где напишу как создавать свои компоненты(например календарь или inputSearch) и их валидировать, как связать с redux и другое.
Поделиться публикацией

Похожие публикации

Комментарии 14
    +1
    Клиентская валидация хорошо работает в JSON Schema — Forms
    github.com/mozilla-services/react-jsonschema-form
      0
      Это только первая часть, в следующих статьях я напишу примеры которые мне кажутся очень удобными и понятными при разработки
      +3
      Чем вам Formik + Yup не угодил? В вашем кейсе не вижу решения для стилизации и кастомизации. Например мне нужно React-Select упаковать в вашу форму, как мне это сделать?

      Форма логина на связке Formik + Yup выглядит куда более читаемой

      import React from 'react';
      import { View, Dimensions } from 'react-native';
      import { Formik } from 'formik';
      import * as Yup from 'yup';
      import { TextInput, Button } from '../components';
      
      const validationSchema = Yup.object().shape({
        username: Yup.string()
          .min(4, 'Too Short!')
          .max(24, 'Too Long!')
          .required('Required'),
        password: Yup.string()
          .min(6, 'Too short password')
          .max(30, 'Too long')
          .required('Required'),
        securityCode: Yup.number()
          .min(99999, 'Too Short!')
          .max(999999, 'Too Long!')
          .required('Required'),
      });
      const { width } = Dimensions.get('window');
      
      const LoginForm = componentProps => (
        <Formik
          validationSchema={validationSchema}
          initialValues={componentProps.initialValues}
          onSubmit={componentProps.onSubmit}
          validateOnChange={false}
          validateOnBlur={false}
        >
          {props => (
            <View style={{ width, padding: 10 }}>
              <TextInput
                placeholder="Username"
                returnKeyType="done"
                onChangeText={props.handleChange('username')}
                error={props.errors.username}
                onBlue={props.handleBlur('username')}
                value={props.values.username}
              />
              <TextInput
                secureTextEntry
                placeholder="Password"
                returnKeyType="done"
                onChangeText={props.handleChange('password')}
                error={props.errors.password}
                onBlue={props.handleBlur('password')}
                value={props.values.password}
              />
              <TextInput
                error={props.errors.securityCode}
                keyboardType="number-pad"
                returnKeyType="done"
                placeholder="Google Authentication code"
                onChangeText={props.handleChange('securityCode')}
                onBlue={props.handleBlur('securityCode')}
                value={props.values.securityCode}
              />
              <Button
                secondary
                rounded
                style={{ alignSelf: 'stretch', marginTop: 35 }}
                caption="Login"
                onPress={props.handleSubmit}
              />
            </View>
          )}
        </Formik>
      );
      
      export default LoginForm;
      
      
        0
        Если забегая немного вперёд, то можно описать
        <InputBlock name="name" />

        И в этом компоненте мы уже и описываем всю конструкцию, label, само поле и выводим ошибки
        0
        Здесь описаны пока простейшие случае, ответ будет в следующей статье.
          0
          Вот это выглядит минимум странно, я уже молчу о том, что у вас чекбокс хранит внутри себя состояние checked, что в целом он делать не должен, а если должен, то должен выполнять проверку на изменения.
          <InputCheckbox name="remember" value="yes" />


          Допустим я выбрал опцию, которая должна задизаблить или отчекнуть чекбокс, ваш чекбокс на это действие не отреагирует.

          Ваш код не проходил код ревью, советую сделать, а также докинуть eslint и, ставший уже стандартным, пакет гайдов от всем известной компании.

            0
            Вопрос как программным способом снять checked, я учту это замечание, большое спасибо.
          +1
          www.npmjs.com/package/react-validation-boo
          Зачем эта копипаста в описание пакета? Где нормальная документация? Где ссылка на github репозиторий?

          Я так понял это ваша велосипед библиотека. Гораздо интересней вместо примера реализации прочитать какие у нее преимущество перед существующими решениями, и что побудило на создание еще одного. Например есть замечательный Final-Form с кучей примеров и отличной документацией
            0
            Чтобы описать отличия, надо сперва описать принцип работы. Это копипаст, потому что я пишу доку и сразу её публикую на github. Да много решений, я не первый, я предлагаю своё виденье, а вам решать что использовать.
            +4
            для валидации форм любой сложности.


            Смело. А как же проблема зависимых друг от друга полей? А как же асинхронные сценарии? Кстати, свой DSL (я про «сценарии») — это очень неудобное решение, в современном приложении и так куча DSL.

            А как же тот факт, что в случае сложных форм проще вынести валидацию в, например, саги, а обёртки компонентов будут просто уметь рисовать ошибки.

            Про код-стайл уже сказали выше, но имена классов с маленькой буквы — это за гранью добра и зла.
            А где можно глянуть исходники, не устанавливая пакет?
              0

              А ещё есть валидация динамических инпутов или групп инпутов. Пример — "Предыдущие места работы".

              –1
              1. Где обработка dirty/touched?
              2. Правильно я понимаю, что нельзя определить тип ошибок в форме?
              3. Почему код, отвечающий за валидацию, отвечает еще и за рендер ошибок? Смесь логики и отображения в худшей ее форме.
              4. Сценарии = привет часам отладки.
              5. Непонятно, как это должно работать с динамическими формами
              6. Как будет выглядеть совместная валидация? Например — то, что повтор пароля совпадает с паролем.
                0
                Мне куда более интересней, как валидировать табличную форму, где помимо допустим имени и фамилии есть поля, которые могут добавляться и удаляться.
                Как валидировать составную форму. Когда есть одна страница и там несколько форм подключается, есть табличные, форма разбита на страницы, а кнопка сохранить одна в конце.
                  0
                  В примере я добавил поле {this.getCommentContent()}, не кто не мешает добавить 10 полей или убрать их

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое