본문 바로가기
web/react

[React/Ant design] Ant design form validation - 회원 가입, 로그인 폼 검증하기 (Form instance/ validator)

by fien 2021. 3. 19.

Ant design validator와 form instance로 form validation하기

: 회원 가입, 로그인 폼 만들고 값 검증하기.

Ant Design은 버전 4를 사용해야 합니다.

Ant design은 Form 컴포넌트의 Item에 대하여 validation 기능을 제공하고 있습니다.

Antd가 제공하는 Rule 필드를 통해 기본적으로 type, required, min length, max length, 정규식 검증이 가능합니다. 필요하다면 custom validation도 가능합니다.

<Form.Item
  name="email"
  label="E-mail"
  rules={[
      {
        type: 'email', 
        message: 'The input is not valid E-mail!',
      },
      {
        required: true,
        message: 'Please input your E-mail!',
      },
        {
        pattern: /^[\s]/,
        message: 'Please do not use space',
      },
    ]}
>
    <Input />
</Form.Item>

각 필드에 대한 유효성 검증을 위해 pattern을 사용했습니다. 그런데 검증 결과에 대한 부정을 할 수 가 없어서 공백 검증이 안됐습니다. 정규식.test(값) 이렇게 해서 false 면 메세지를 보여주는 방식이라 !정규식.test(값)처럼 검증 결과를 부정해서 메세지를 보여줄 수가 없었습니다.

그래서 별도의 validator 를 만드는 방식으로 했습니다.

아래 코드는 닉네임을 검증하는 부분입니다.

const SignupForm = () => {
  // antd form control
  const [form] = Form.useForm();
    ...

  // nickname 유효성 검사
  // length 2~20, english, korean, number
  const validateNickname = useCallback((_, value) => {
    if (!value) {
      return Promise.reject(new Error('닉네임은 필수 항목입니다.'));
    }
    if (/\s/.test(value)) {
      return Promise.reject(new Error('닉네임은 공백을 포함 할 수 없습니다.'));
    }

    let nicknameLength = 0;
    for (let i = 0; i < value.length; i += 1) {
      const char = value.charAt(i);
      if (escape(char).length > 4) {
        nicknameLength += 2;
      } else {
        nicknameLength += 1;
      }
    }
    if (nicknameLength < 2 || nicknameLength >= 20) {
      return Promise.reject(new Error('닉네임 한글1~10자, 영문 및 숫자 2~20자까지 입력가능합니다.'));
    }

    const regExp = /[^a-zA-Z0-9가-힣_]/;
    if (regExp.test(value)) {
      return Promise.reject(new Error('닉네임은 한글, 영문, 숫자, _ 만 사용할 수 있습니다.'));
    }
    return Promise.resolve();
  }, []);

  // nickname 중복 검사
  const onBlurNickname = useCallback(() => {
    if (form.getFieldError('nickname').length === 0 && form.getFieldValue('nickname')) {
      axios.get(`/join/check-nickname?nickname=${form.getFieldValue('nickname')}`)
        .catch((e) => {
          console.error(e);
        }).then((response) => {
          if (!response.data) {
            form.setFields([{
              name: 'nickname',
              errors: ['사용중인 닉네임 입니다.'],
            }]);
          }
        });
    }
  }, []);

    ...

    return (

    ...
        <Form.Item
          style={{ marginBottom: 0 }}
          name="nickname"
          hasFeedback
          rules={[{ validator: validateNickname }]}
        >
          <Input
            placeholder="닉네임"
            value={nickname}
            onChange={onChangeNickname}
            onBlur={onBlurNickname}
            allowClear
          />
        </Form.Item>
        ...

    );
}

Form.Item 에 rules에 validator로 사용자가 작성한 validation function을 넘겨줍니다. validator는 두 번째 인자로 해당 Form.Item의 value 값을 받습니다. 값을 검증하여 Promise.reject()Promise.resolve() 를 반환합니다. 이렇게 등록한 validator는 기본적으로 onChange 상황에서 동작하여 Form을 submit 할 때도 검증을 수행하게 됩니다.

 

서버에 요청을 보내는 닉네임 중복 검사는 닉네임 작성이 완료되어 Input에서 포커스가 떠나는 때에 이뤄지도록 만들었습니다. <Input onBlur={onBlurNickname} />

 

validator의 경우는 value를 전달해주지만 이 외의 경우에는 폼 인스턴스를 생성해 값을 찾아야 합니다. const [form] = Form.useForm();

 

** useForm은 리액트 훅이라서 함수형 컴포넌트에서 사용가능합니다.

 

폼 인스턴스의 getFielidValue 메서드에 인자로 Form.Item의 name을 넘겨주면 해당 값을 찾을 수 있습니다.

 

닉네임 검증을 통과하여 에러가 메세지가 없고 form.getFieldError('nickname').length === 0 값이 비어있지 않으면 서버에 요청을 보내 중복 검사를 합니다.

 

중복인 경우에는 에러 메세지를 보여줍니다. form.setFields([ {name: 'nickname', errors: ['사용중인 닉네임 입니다.']} ]);

 

form 관련 메서드는 아래에서 확인 할 수 있습니다.

https://ant.design/components/form/#FormInstance

 

이메일도 이와 같은 식으로 처리하면 되고

비밀번호 확인 부분은 form.getFielidValue() 로 비밀번호 값을 가져와서 처리하면 됩니다.

 

참고로 validator를 등록할 때 검증이 이뤄지는 시점(trigger)를 등록할 수 있습니다. Input에 onBlur를 이용하는 방식이 아니라 validator를 여러 개 등록하고 validateTrigger를 다르게 주는 식으로도 처리할 수 도 있습니다. 다만 validator 내부에서 form.getFieldError() 로 자신의 에러를 찾으면 빈 리스트가 반환되기 떄문에 에러 발생 여부를 다르게 저장하고 처리해야합니다. 이 방식으로 바꿔보다가.. 여기서 귀찮아서 그냥 접었습니다.

<Form.Item
  style={{ marginBottom: 0 }}
  name="nickname"
  hasFeedback
  rules={[
		{ validator: validateNickname, validateTrigger: 'onChange' },
		{ validator: chkDuplicateNickname, validateTrigger: 'onBlur' },
	]}
	validateTrigger={['onChange', 'onBlur']}
>

 

 

전체 소스

import React, { useCallback } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import request from '@/utils/request'; // axios instance
import useInput from '@/hooks/useInput';
import { Space, Button, Input, Form, Modal } from 'antd';
import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
import styled from 'styled-components';

const StyledForm = styled.div`
  max-width:100%;
  margin: 0 auto;
  text-align: center;
  padding: 10px;
  @media (min-width: 450px) {
    max-width:70%;
  }
  @media (min-width: 701px) {
    max-width: 300px;
  }
`;

const SignupForm = () => {
  const [nickname, onChangeNickname] = useInput('');
  const [email, onChangeEmail] = useInput('');
  const [password, onChangePassword] = useInput('');
  const [passwordCheck, onChangePasswordCheck] = useInput('');

  const router = useRouter();
  // antd form control
  const [form] = Form.useForm();

  // 회원가입 요청
  // 버튼을 누르면 validation이 완료된 후에 실행되서 각 필드를 다시 확인안해도됨
  const onsubmitForm = useCallback(({ email, nickname, password }) => {
    request.post('/join', { email, nickname, password })
      .catch((e) => {
        console.error(e);
      })
      .then((response) => {
        if (response.status === 200) {
          Modal.success({
            title: '회원가입이 완료되었습니다.',
            onOk() {
              router.push('/signin');
            },
          });
        }
      });
  }, []);

  // nickname 유효성 검사
  // length 2~20, english, korean, number
  const validateNickname = useCallback((_, value) => {
    if (!value) {
      return Promise.reject(new Error('닉네임은 필수 항목입니다.'));
    }
    if (/\s/.test(value)) {
      return Promise.reject(new Error('닉네임은 공백을 포함 할 수 없습니다.'));
    }

    let nicknameLength = 0;
    for (let i = 0; i < value.length; i += 1) {
      const char = value.charAt(i);
      if (escape(char).length > 4) {
        nicknameLength += 2;
      } else {
        nicknameLength += 1;
      }
    }
    if (nicknameLength < 2 || nicknameLength >= 20) {
      return Promise.reject(new Error('닉네임 한글1~10자, 영문 및 숫자 2~20자까지 입력가능합니다.'));
    }

    const regExp = /[^a-zA-Z0-9가-힣_]/;
    if (regExp.test(value)) {
      return Promise.reject(new Error('닉네임은 한글, 영문, 숫자, _ 만 사용할 수 있습니다.'));
    }
    return Promise.resolve();
  }, []);

  // nickname 중복 검사
  const onBlurNickname = useCallback(() => {
    if (form.getFieldError('nickname').length === 0 && form.getFieldValue('nickname')) {
      request.get(`/join/check-nickname?nickname=${form.getFieldValue('nickname')}`)
        .catch((e) => {
          console.error(e);
        }).then((response) => {
          if (!response.data) {
            form.setFields([{
              name: 'nickname',
              errors: ['사용중인 닉네임 입니다.'],
            }]);
          }
        });
    }
  }, []);

  // email 유효성 검사
  const validateEmail = useCallback((_, value) => {
    const regExp = /^[A-Za-z0-9_\.\-]+@[A-Za-z0-9\-]+\.[A-Za-z0-9\-]+/;
    if (!value) {
      return Promise.reject(new Error('이메일은 필수 항목입니다.'));
    }
    if (!value.match(regExp)) {
      return Promise.reject(new Error('올바른 이메일 형식이 아닙니다.'));
    }
    return Promise.resolve();
  }, []);

  // email 중복 검사
  const onBlurEmail = useCallback(() => {
    if (form.getFieldError('email').length === 0 && form.getFieldValue('email')) {
      request.get(`/join/check-email?email=${form.getFieldValue('email')}`)
        .catch((e) => {
          console.error(e);
        }).then((response) => {
          if (!response.data) {
            form.setFields([{
              name: 'email',
              errors: ['사용중인 이메일 입니다.'],
            }]);
          }
        });
    }
  }, []);

  // password 유효성 검사
  const validatePassword = useCallback((_, value) => {
    const regExp = /(?=.*\d{1,50})(?=.*[~`!@#$%\^&*()-+=]{1,50})(?=.*[a-z]{1,50})(?=.*[A-Z]{1,50}).{8,50}$/;
    if (!value) {
      return Promise.reject(new Error('비밀번호는 필수 항목입니다.'));
    }
    if (!regExp.test(value)) {
      return Promise.reject(new Error('비밀번호는 8~50자이며 영문 소문자, 영문 대문자, 숫자, 특수문자를 모두 포함해야 합니다.'));
    }
    return Promise.resolve();
  }, []);

  // passwordCheck 유효성 검사
  const validatePasswordCheck = useCallback((_, value) => {
    if (form.getFieldValue('password') && form.getFieldValue('password') !== value) {
      return Promise.reject(new Error('비밀번호가 일치하지 않습니다.'));
    }
    return Promise.resolve();
  }, []);

  return (
    <StyledForm>
      <Space direction="vertical" style={{ width: '100%' }}>
        <h3>회원 가입</h3>
        <Form onFinish={onsubmitForm} form={form}>
          <Space direction="vertical" style={{ width: '100%' }}>
            <Form.Item
              style={{ marginBottom: 0 }}
              name="nickname"
              hasFeedback
              rules={[{ validator: validateNickname }]}
            >
              <Input
                placeholder="닉네임"
                value={nickname}
                onChange={onChangeNickname}
                onBlur={onBlurNickname}
                allowClear
              />
            </Form.Item>
            <Form.Item
              style={{ marginBottom: 0 }}
              name="email"
              hasFeedback
              rules={[{ validator: validateEmail }]}
            >
              <Input
                placeholder="이메일"
                type="email"
                value={email}
                onChange={onChangeEmail}
                onBlur={onBlurEmail}
                allowClear
              />
            </Form.Item>
            <Form.Item
              style={{ marginBottom: 0 }}
              name="password"
              hasFeedback
              rules={[{ validator: validatePassword }]}
            >
              <Input.Password
                placeholder="비밀번호"
                value={password}
                onChange={onChangePassword}
                iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
                allowClear
              />
            </Form.Item>
            <Form.Item
              style={{ marginBottom: 0 }}
              name="password-check"
              hasFeedback
              dependencies={['password']}
              rules={[{ validator: validatePasswordCheck }]}
            >
              <Input.Password
                placeholder="비밀번호 확인"
                value={passwordCheck}
                onChange={onChangePasswordCheck}
                iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
                allowClear
              />
            </Form.Item>
            <Button type="primary" htmlType="submit" block>회원 가입</Button>
          </Space>
        </Form>
        <div>
          이미 가입하셨나요?
          <Link href="/signin"><Button type="link" size={10} style={{ padding: '0 0 0 5px' }}>로그인</Button></Link>
        </div>
      </Space>
    </StyledForm>
  );
};

export default SignupForm;

위 코드에서 사용한 useInput은 custom hook입니다.

// useInput.js

export default (initialValue = null) => {
  const [value, setValue] = useState(initialValue);
  const handler = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  return [value, handler, setValue];
};

 

 

 

 

그냥 p태그하나 추가해서 처리해도 되는데 antd에서 제공하는 걸로 일관되게 해보고 싶어서 혼자 이래저래 삽질함.. 괜한 집착.. 하지만 나와 같은 사람 분명 있다 소리질러 아악~~

그리고 이 스킨.. 백틱으로 감싸면.. 이도저도 아니게 보이네.. 쩝.. 

댓글