[RedwoodJS] (4) RedwoodJS 기초: Form 생성 및 데이터 저장

winluck·2024년 2월 27일
0

RedwoodJS

목록 보기
4/9

Form 생성

지난 게시물에 이어 이제 문의사항을 작성하는 contact Form을 추가해보자.

yarn rw g page contact

BlogLayout

import {Link, routes} from '@redwoodjs/router'
import React from "react";

type BlogLayoutProps = {
  children?: React.ReactNode
}

const BlogLayout = ({ children }: BlogLayoutProps) => {
  return (
    <>
      <header>
        <h1>
          <Link to={routes.home()}>Redwood Blog</Link>
        </h1>
        <nav>
          <ul>
            <li>
              <Link to={routes.home()}>Home</Link>
            </li>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
            <li>
              <Link to={routes.contact()}>Contact</Link>
            </li>
          </ul>
        </nav>
      </header>
      <main>{children}</main>
    </>
  )
}

export default BlogLayout

Routes.tsx 파일에도 한 줄 추가하자.

ContactPage.tsx

import { Metadata } from '@redwoodjs/web'
import { Form, TextField } from '@redwoodjs/forms'

const ContactPage = () => {
  return (
    <>
      <Metadata title="Contact" description="Contact page" />

      <Form>
        <TextField name="input" />
        <Submit>Save</Submit>
      </Form>
    </>
  )
}

export default ContactPage

Contact를 눌러 텍스트필드 및 save가 나타나는 것을 확인했다

이제 여러 필드에서 데이터를 추출해보자. onSubmit 핸들러는 제출된 Form의 필드들에 대한 정보를 단일 매개변수로 담고 있다.

ContactPage.tsx

import { Metadata } from '@redwoodjs/web'
import {
  Form,
  TextField,
  TextAreaField,
  Submit,
  SubmitHandler
} from '@redwoodjs/forms'

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data)
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page"/>

      <Form onSubmit={onSubmit}>
        <label htmlFor="name">Name</label>
        <TextField name="name" required />

        <label htmlFor="email">Email</label>
        <TextField name="email" required />

        <label htmlFor="message">Message</label>
        <TextAreaField name="message" required />

        <Submit>Save</Submit>
      </Form>
    </>
  )
}

export default ContactPage

required 속성을 통해 해당 필드에 반드시 입력되어야 함을 나타낼 수 있다.

하지만 단순 입력을 넘어 이메일 등의 경우 이메일 형식이 맞는지에 대한 유효성 검사가 추가적으로 필요하다.
이런 필드에 대한 유효성 검사와, 검사 실패 시 에러 표시를 위해 Validation/FieldError를 활용할 수 있다.

import { Metadata } from '@redwoodjs/web'
import {
  FieldError,
  Form,
  TextField,
  TextAreaField,
  Submit,
  SubmitHandler
} from '@redwoodjs/forms'

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data)
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page"/>

      <Form onSubmit={onSubmit}>
        <label htmlFor="name">Name</label>
        <TextField name="name" validation={{ required: true }} />
        <FieldError name="name" className="error" />

        <label htmlFor="email">Email</label>
        <TextField name="email" validation={{ required: true }} />
        <FieldError name="email" className="error" />

        <label htmlFor="message">Message</label>
        <TextAreaField name="message" validation={{ required: true }} />
        <FieldError name="message" className="error" />

        <Submit>Save</Submit>
      </Form>
    </>
  )
}

export default ContactPage

유효성 검사에 실패할 경우 빨간 경고 텍스트가 발생하는 것을 확인하였다.
당연히 Label이나 TextField에도 에러 시 스타일을 errorClassName을 통해 적용할 수 있다.

import { Metadata } from '@redwoodjs/web'
import {
  FieldError,
  Form,
  TextField,
  TextAreaField,
  Submit,
  SubmitHandler, Label
} from '@redwoodjs/forms'

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data)
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page"/>

      <Form onSubmit={onSubmit}>
        <Label name="name" errorClassName="error">
          Name
        </Label>
        <TextField
          name="name"
          validation={{ required: true }}
          errorClassName="error"
        />
        <FieldError name="name" className="error" />

        <Label name="email" errorClassName="error">
          Email
        </Label>
        <TextField
          name="email"
          validation={{ required: true }}
          errorClassName="error"
        />
        <FieldError name="email" className="error" />

        <Label name="message" errorClassName="error">
          Message
        </Label>
        <TextAreaField
          name="message"
          validation={{ required: true }}
          errorClassName="error"
        />
        <FieldError name="message" className="error" />
        <Submit>Save</Submit>
      </Form>
    </>
  )
}

export default ContactPage

이메일에 대한 유효성 검사는 validation에 아래와 같은 pattern을 추가하여 처리한다.

  validation={{
    required: true,
    pattern: {
      value: /^[^@]+@[^.]+\..+$/,
      message: 'Please enter a valid email address',
    },
  }}

value에 매칭되지 않을 경우 아래 에러 메시지가 나타나는 것이다. 참고로 확인 버튼이 아닌 실시간으로 입력할 때마다 유효성 검사가 이루어진다.

유효성 검사를 모두 통과하면 아무 에러도 나타나지 않는다.

참고로 현재 필드가 유효성 검사가 실패했는데 다른 필드로 넘어가는 경우에 직전 필드에 대한 경고를 출력하고 싶다면, onSubmit={onSubmit} config={{mode: 'onBlur'}} 으로 설정하면 된다.

데이터 저장

위에서 만든 Form을 제출하면 이 데이터를 DB에 저장해보자.

api/db/schema.prisma에 아래와 같은 DB 스키마를 추가한다.

  model Contact {
  id        Int      @id @default(autoincrement())
  name      String
  email     String
  message   String
  createdAt DateTime @default(now())
  }

참고로 Nullable하게, 즉 선택사항으로 필드를 만들고 싶다면 String?으로 표기하면 된다.
Kotlin과 유사하다.

yarn rw prisma migrate dev
yarn rw g sdl Contact

데이터베이스에 해당 변경사항을 반영하고, Contact를 위한 GraphQL interface를 생성하자.
sdl 파일은 GraphQL의 스키마 언어를 정의할 것이고, contacts.ts 파일은 이를 기반으로 한 비즈니스 로직을 담고 있는 Service이다. GraphQL sdl 파일에서 주고받을 때 요구되는 데이터 형식을 관리할 수 있기에 Dto가 따로 필요없다는 것은 확실한 강점으로 보인다.

contacts.sdl.ts

  export const schema = gql`
  type Contact {
    id: Int!
    name: String!
    email: String!
    message: String!
    createdAt: DateTime!
  }

  type Query {
    contacts: [Contact!]! @requireAuth
    contact(id: Int!): Contact @requireAuth
  }

  input CreateContactInput {
    name: String!
    email: String!
    message: String!
  }

  input UpdateContactInput {
    name: String
    email: String
    message: String
  }

  type Mutation {
    createContact(input: CreateContactInput!): Contact! @requireAuth
    updateContact(id: Int!, input: UpdateContactInput!): Contact! @requireAuth
    deleteContact(id: Int!): Contact! @requireAuth
  }
`

참고로 ! 기호는 Not Nullable임을 의미한다.

또한 @requireAuth 어노테이션으로 이 GraphQL 쿼리에 접근하려면 사용자가 인증(로그인)을 수행해야 함을 표시할 수 있다.
다만 인증 추가 전까지는 @requireAuth 뒤에 있는 함수가 항상 true를 반환하므로 지금은 의미없다.

참고로 graphql과 클라이언트가 주고받게 될 데이터를 raw한 형태로 확인하고자 한다면

http://localhost:8911/graphql

GraphQL의 단일 엔드포인트이며, 해당 URL에 접근하면 쿼리 관련 기능을 제공한다.
여러모로 풀스택의 편의성을 극대화하려는 게 보인다.

이제 폼을 제출하면 해당 폼의 필드에 DB에 등록되도록 하자.

ContactPage.tsx

import {Metadata, useMutation} from '@redwoodjs/web'
import {
  FieldError,
  Form,
  TextField,
  TextAreaField,
  Submit,
  SubmitHandler, Label
} from '@redwoodjs/forms'

import {
  CreateContactMutation,
  CreateContactMutationVariables,
} from 'types/graphql'

const CREATE_CONTACT = gql`
  mutation CreateContactMutation($input: CreateContactInput!) {
    createContact(input: $input) {
      id
    }
  }
`

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const [create] = useMutation<
    CreateContactMutation,
    CreateContactMutationVariables
  >(CREATE_CONTACT)

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    create({variables: { input: data} })
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page"/>

      <Form onSubmit={onSubmit} config={{mode: 'onBlur'}}>
        <Label name="name" errorClassName="error">
          Name
        </Label>
        <TextField
          name="name"
          validation={{required: true}}
          errorClassName="error"
        />
        <FieldError name="name" className="error"/>

        <Label name="email" errorClassName="error">
          Email
        </Label>
        <TextField
          name="email"
          validation={{
            required: true,
            pattern: {
              value: /^[^@]+@[^.]+\..+$/,
              message: 'Please enter a valid email address',
            },
          }}
          errorClassName="error"
        />
        <FieldError name="email" className="error"/>

        <Label name="message" errorClassName="error">
          Message
        </Label>
        <TextAreaField
          name="message"
          validation={{required: true}}
          errorClassName="error"
        />
        <FieldError name="message" className="error"/>
        <Submit>Save</Submit>
      </Form>
    </>
  )
}

export default ContactPage
  • CREATE_CONTACT: Form을 제출할 때 실행될 GraphQL 뮤테이션을 정의하고, CreateContactInput 타입(Form 데이터)의 입력을 받아 새로운 데이터를 생성한 후 그 ID를 반환한다.

  • 참고로 React-Query에서 뮤테이션은 DB 내부 데이터에 대한 Create/Update/Delete를 위해 DB를 호출하는 것으로 이해하였다.

  • useMutation: useMutation을 사용하여 뮤테이션을 실행하는 함수를 생성하며, Form 데이터는 뮤테이션의 변수로 전달된다.

onSubmit 핸들러를 통해 제출 시 create 함수를 호출하여 뮤테이션을 실행하게 된다.
이제 폼을 작성하여 save 버튼을 누른 뒤 엔드포인트 URL에 접근해 쿼리를 날려보자.

문제 없이 잘 저장되었다. 그 외 자잘한 수정이 더 필요하다.

ContactPage.tsx

import {Metadata, useMutation} from '@redwoodjs/web'
**import { toast, Toaster } from '@redwoodjs/web/toast'**
import {
  FieldError,
  Form,
  TextField,
  TextAreaField,
  Submit,
  SubmitHandler, Label
} from '@redwoodjs/forms'

import {
  CreateContactMutation,
  CreateContactMutationVariables,
} from 'types/graphql'

const CREATE_CONTACT = gql`
  mutation CreateContactMutation($input: CreateContactInput!) {
    createContact(input: $input) {
      id
    }
  }
`

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const [create, { loading, error }] = useMutation<
    CreateContactMutation,
    CreateContactMutationVariables
  >(CREATE_CONTACT, {
    **onCompleted: () => {
      toast.success('Thank you for your submission!')
    },**
  })

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    create({variables: { input: data} })
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page"/>

      **<Toaster />**
      <Form onSubmit={onSubmit} config={{mode: 'onBlur'}}>
        <Label name="name" errorClassName="error">
          Name
        </Label>
        <TextField
          name="name"
          validation={{required: true}}
          errorClassName="error"
        />
        <FieldError name="name" className="error"/>

        <Label name="email" errorClassName="error">
          Email
        </Label>
        <TextField
          name="email"
          validation={{
            required: true,
            pattern: {
              value: /^[^@]+@[^.]+\..+$/,
              message: 'Please enter a valid email address',
            },
          }}
          errorClassName="error"
        />
        <FieldError name="email" className="error"/>

        <Label name="message" errorClassName="error">
          Message
        </Label>
        <TextAreaField
          name="message"
          validation={{required: true}}
          errorClassName="error"
        />
        <FieldError name="message" className="error"/>
       **<Submit disabled={loading}>Save</Submit>**
      </Form>
    </>
  )
}

export default ContactPage

정상적으로 뮤테이션이 완료되었다면 Toaster 메시지를 출력하고, save를 짧은 시간에 여러 번 누르는 동작을 막기 위해 disabled=loading 속성을 추가하면 버튼을 누른 직후 1~2초 간 disabled 처리된다.

유효성 검사는 클라이언트 사이드에서만 수행할 수 있을까? Springboot의 Validation 기능처럼 RedwoodJS 역시 서버 사이드에서 유효성 검사 기능을 제공한다.

Contact.ts

import type { QueryResolvers, MutationResolvers } from 'types/graphql'
**import { validate } from '@redwoodjs/api'**
import { db } from 'src/lib/db'

export const contacts: QueryResolvers['contacts'] = () => {
  return db.contact.findMany()
}

export const contact: QueryResolvers['contact'] = ({ id }) => {
  return db.contact.findUnique({
    where: { id },
  })
}

export const createContact: MutationResolvers['createContact'] = ({
  input,
}) => {
  **validate(input.email, 'email',{ email: true })**
  return db.contact.create({
    data: input,
  })
}

ContactsPage.tsxdml 아래 두 줄을 수정하자.

      <Form onSubmit={onSubmit} config={{mode: 'onBlur'}} error={error}>
        <FormError error={error} wrapperClassName={"form-error"}/>


작성된 폼에 대해 에러가 발생했음을 나타낼 수 있으며, 관련 로그는 아래와 같다.

만약 특정 필드가 아닌 대부분의 필드에 유효성 검사가 필요하다면, 서비스 내에서 built-in validate 함수를 사용할 수도 있다.

export const createCar = ({ input }: Car) => {
  validate(input.make, 'make', {
    inclusion: ['Audi', 'BMW', 'Ferrari', 'Lexus', 'Tesla'],
  })
  validate(input.color, 'color', {
    exclusion: { in: ['Beige', 'Mauve'], message: "No one wants that color" }
  })
  validate(input.hasDamage, 'hasDamage', {
    absence: true
  })
  validate(input.vin, 'vin', {
    format: /[A-Z0-9]+/,
    length: { equal: 17 }
  })
  validate(input.odometer, 'odometer', {
    numericality: { positive: true, lessThanOrEqual: 10000 }
  })

  return db.car.create({ data: input })
}

또한 서비스마다 비즈니스 로직에 위배되는 경우를 잡아내는 유효성 검사 기준은 천차만별이기에, 커스터마이징 역시 가능하다고 한다.

validateWith(() => {
  const oneWeekAgo = new Date()
  oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)

  if (input.lastCarWashDate < oneWeekAgo) {
    throw new Error("We don't accept dirty cars")
  }
})

또한 작성을 마친 Form은 초기화되는 것이 바람직하기에, useForm 기능을 활용해 Form을 초기화하는 것을 추가하자.

최종 ContactPage.tsx

import {Metadata, useMutation} from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import {
  FieldError,
  Form,
  FormError,
  TextField,
  TextAreaField,
  useForm,
  Submit,
  SubmitHandler, Label
} from '@redwoodjs/forms'

import {
  CreateContactMutation,
  CreateContactMutationVariables,
} from 'types/graphql'

const CREATE_CONTACT = gql`
  mutation CreateContactMutation($input: CreateContactInput!) {
    createContact(input: $input) {
      id
    }
  }
`

interface FormValues {
  name: string
  email: string
  message: string
}

const ContactPage = () => {
  const formMethods = useForm()
  const [create, { loading, error }] = useMutation<
    CreateContactMutation,
    CreateContactMutationVariables
  >(CREATE_CONTACT, {
    onCompleted: () => {
      toast.success('Thank you for your submission!')
      formMethods.reset()
    },
  })

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    create({variables: { input: data} })
  }

  return (
    <>
      <Metadata title="Contact" description="Contact page"/>

      <Toaster />
      <Form onSubmit={onSubmit} config={{mode: 'onBlur'}} error={error} formMethods={formMethods}>
        <FormError error={error} wrapperClassName={"form-error"}/>
        <Label name="name" errorClassName="error">
          Name
        </Label>
        <TextField
          name="name"
          validation={{required: true}}
          errorClassName="error"
        />
        <FieldError name="name" className="error"/>

        <Label name="email" errorClassName="error">
          Email
        </Label>
        <TextField
          name="email"
          validation={{
            required: true
          }}
          errorClassName="error"
        />
        <FieldError name="email" className="error"/>

        <Label name="message" errorClassName="error">
          Message
        </Label>
        <TextAreaField
          name="message"
          validation={{required: true}}
          errorClassName="error"
        />
        <FieldError name="message" className="error"/>
        <Submit disabled={loading}>Save</Submit>
      </Form>
    </>
  )
}

export default ContactPage

이렇게 프런트엔드의 기본 소양 Form 작성에 대해 알아보았다.
이제 로그인 및 배포에 대해 다루어보겠다.

profile
Discover Tomorrow

0개의 댓글