All Posts

GraphQL+React로 게시판 만들기 마지막편

Post type

Post type fields




지난 글에서는 정말 간단하게 게시판의 CRUD를 구현해보았습니다. 이번에는 GraphQL 로 게시판 만들기 시리즈 마지막 글인만큼 리팩토링을 통해 조금은 더 완성도 있는 게시판을 만들어보도록 하겠습니다.

수정, 보완할 점들을 나열해보고 하나씩 차근차근 고쳐보도록 하겠습니다.

  • 여러 Component 에서 똑같은 Query 를 계속 재작성함
  • Mutation 이후 새로 고침해야 내용이 반영됨
  • 하나의 Component(PostDetail)에서 너무 많은 기능을 수행함
  • 자신이 쓴 글 외에 다른 사람이 쓴 글도 수정, 삭제가 가능함 😨
  • 게시물 작성 시 Form validation 없음
  • Pagination 기능 없음

하… 고쳐야 할 게 산더미네요; 한 번 달려봅시다!

Query 재활용

여러 Component 에서 자주 쓰이는 loggedInUserQuerypostQuery, 그리고 allPostsQueryqueries 디렉토리에 따로 정리해두고 각 Component 에서 이를 import 하는 방식으로 고쳐보겠습니다.

// src/queries/queries.js
import gql from 'graphql-tag'

const allPostsQuery = gql`
  {
    allPosts(orderBy: createdAt_DESC) {
      id
      title
      author {
        name
      }
      comments {
        id
      }
      createdAt
    }
  }
`

const loggedInUserQuery = gql`
  {
    loggedInUser {
      id
    }
  }
`

const postQuery = gql`
  query PostQuery($id: ID!) {
    Post(id: $id) {
      id
      author {
        id
        name
      }
      title
      content
      imageUrl
      createdAt
    }
  }
`

export { allPostsQuery, loggedInUserQuery, postQuery }

그럼 이제 이렇게 정리된 Query들을 여러 곳에서 재활용하면 됩니다.

App.js

src/components/App.js


ListPage.js

src/components/ListPage.js


CreateUser.js

src/components/CreateUser.js


Loginuser.js

src/components/LoginUser.js


Refetching query

지금까지는 게시물을 작성하고 나서 ListPage 로 리다이렉트된 이후 새로고침을 해줘야만 새롭게 생성된 Post 가 게시판에 반영되었습니다. 이는 정말 간단하게 mutationrefetchQueriesallPostsQuery를 넘겨주면 해결할 수 있습니다.

CreatePost.js

src/components/CreatePost.js


거대한 PostDetail Component 작게 분리하기

처음에는 단순히 게시물의 내용을 보여주려는 상세 페이지로서 PostDetail 을 만들었지만, 게시물을 수정, 삭제하는 로직을 무분별하게 추가하다보니 중복된 코드도 존재하고 별 기능도 없는데 221 라인의 거대한 Component 가 되어버렸습니다. 보기만해도 짜증나니 이를 잘게 쪼개봅시다.

// src/components/PostDetail.js
import React from 'react'
import { graphql, compose } from 'react-apollo'
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom'
import gql from 'graphql-tag'
import UpdatePostButton from './UpdatePostButton'
import DeletePostButton from './DeletePostButton'
import { postQuery } from '../queries/queries'

class PostDetail extends React.Component {
  constructor(props) {
    super(props)
  }

  render() {
    if (this.props.postQuery.loading) {
      return (
        <div className="flex w-100 h-100 items-center justify-center pt7">
          <div>Loading...</div>
        </div>
      )
    }

    const { Post } = this.props.postQuery

    return (
      <article className="baskerville pb5" style={{ marginTop: '10px' }}>
        <Link to="/" style={{ margin: '10px' }}>
          <span className="f6 link dim br3 ba bw1 ph3 pv2 mb2 dib purple">
            뒤로가기
          </span>
        </Link>
        <UpdatePostButton />
        <DeletePostButton />

        <header className="avenir tc-l ph3 ph4-ns pt4 pt5-ns">
          <h1 className="f3 f2-m f-subheadline-l measure lh-title fw1 mt0">
            {Post.title}
          </h1>
          <h4 className="f3 fw4 i lh-title mt0">{Post.author.name}</h4>
          <time className="f5 f4-l db fw1 baskerville mb4">
            {Post.createdAt.split('T', 1)}
          </time>
        </header>
        <div className="measure db center f5 f4-ns lh-copy">
          <img className="db w-100 mt4 mt5-ns" src={Post.imageUrl} />
          <br />
          <p>{Post.content}</p>
        </div>
      </article>
    )
  }
}

export default compose(
  graphql(postQuery, {
    name: 'postQuery',
    options: ({ match }) => ({
      variables: {
        id: match.params.id,
      },
    }),
  })
)(withRouter(PostDetail))

수정, 삭제하는 로직은 각각 UpdatePostButtonDeletePostButton에서 처리해줍니다.

// src/components/UpdatePostButton.js
import React from 'react'
import { graphql, compose } from 'react-apollo'
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom'
import gql from 'graphql-tag'
import { Modal } from 'antd'
import { allPostsQuery, postQuery } from '../queries/queries'

class UpdatePostButton extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      visible: false,
      confirmLoading: false,
      ...props,
    }
  }

  showModal = () => {
    this.setState({
      visible: true,
    })
  }

  handleCancel = () => {
    this.setState({
      visible: false,
    })
  }

  render() {
    const { Post } = this.props.postQuery
    const { visible, confirmLoading } = this.state

    return (
      <div>
        <a
          className="f6 link dim br3 ba bw1 ph3 pv2 mb2 dib light-purple"
          onClick={this.showModal}
          style={{ float: 'right', marginRight: '10px', marginTop: '-50px' }}
        >
          수정하기
        </a>
        <Modal
          title="Update Post"
          visible={visible}
          onOk={this.handleUpdate}
          confirmLoading={confirmLoading}
          onCancel={this.handleCancel}
        >
          <div className="w-100 pa4 flex justify-center">
            <div style={{ maxWidth: 800 }} className="">
              <input
                className="w-100 pa3 mv2"
                placeholder="title"
                defaultValue={Post.title}
                onChange={e => this.setState({ title: e.target.value })}
              />
              <input
                className="w-100 pa3 mv2"
                placeholder="content"
                defaultValue={Post.content}
                onChange={e => this.setState({ content: e.target.value })}
              />
              <input
                className="w-100 pa3 mv2"
                placeholder="imageUrl"
                defaultValue={Post.imageUrl}
                onChange={e => this.setState({ imageUrl: e.target.value })}
              />
              {this.state.imageUrl && (
                <img src={this.state.imageUrl} alt="" className="w-100 mv3" />
              )}
            </div>
          </div>
        </Modal>
      </div>
    )
  }

  handleUpdate = async () => {
    await this.props.updatePostMutation({
      variables: {
        id: this.props.postQuery.Post.id,
        title: this.state.title || this.props.postQuery.Post.title,
        content: this.state.content || this.props.postQuery.Post.content,
        imageUrl: this.state.imageUrl || this.props.postQuery.Post.imageUrl,
      },
      refetchQueries: [{ query: allPostsQuery }],
    })
    this.setState({ visible: false })
    this.props.history.replace('/')
  }
}

const UPDATE_POST_MUTATION = gql`
  mutation UpdatePostMutation(
    $id: ID!
    $title: String!
    $content: String!
    $imageUrl: String!
  ) {
    updatePost(id: $id, title: $title, content: $content, imageUrl: $imageUrl) {
      id
    }
  }
`

const UpdatePostWithGraphQL = compose(
  graphql(postQuery, {
    name: 'postQuery',
    options: ({ match }) => ({
      variables: {
        id: match.params.id,
      },
    }),
  }),
  graphql(UPDATE_POST_MUTATION, {
    name: 'updatePostMutation',
  })
)(UpdatePostButton)

export default compose(
  graphql(UPDATE_POST_MUTATION, { name: 'updatePostMutation' })
)(withRouter(UpdatePostWithGraphQL))
// src/components/DeletePostButton.js
import React from 'react'
import { graphql, compose } from 'react-apollo'
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom'
import gql from 'graphql-tag'
import { Modal } from 'antd'
import { postQuery } from '../queries/queries'

class DeletePostButton extends React.Component {
  constructor(props) {
    super(props)

    this.state = {
      ModalText: '정말 삭제하시겠습니까?',
      visible: false,
      confirmLoading: false,
      ...props,
    }
  }

  showModal = () => {
    this.setState({
      visible: true,
    })
  }

  handleCancel = () => {
    this.setState({
      visible: false,
    })
  }

  render() {
    const { ModalText, visible, confirmLoading } = this.state

    return (
      <div>
        <a
          className="f6 link dim br3 ba bw1 ph3 pv2 mb2 dib hot-pink"
          onClick={this.showModal}
          style={{ float: 'right', marginRight: '110px', marginTop: '-50px' }}
        >
          삭제하기
        </a>
        <Modal
          title="Delete Post"
          visible={visible}
          onOk={this.handleDelete}
          confirmLoading={confirmLoading}
          onCancel={this.handleCancel}
        >
          <p>{ModalText}</p>
        </Modal>
      </div>
    )
  }

  handleDelete = async () => {
    await this.props.deletePostMutation({
      variables: { id: this.props.postQuery.Post.id },
    })
    this.setState({ visible: false })
    this.props.history.replace('/')
  }
}

const DELETE_POST_MUTATION = gql`
  mutation DeletePostMutation($id: ID!) {
    deletePost(id: $id) {
      id
    }
  }
`

const DeletePostWithGraphQL = compose(
  graphql(postQuery, {
    name: 'postQuery',
    options: ({ match }) => ({
      variables: {
        id: match.params.id,
      },
    }),
  }),
  graphql(DELETE_POST_MUTATION, {
    name: 'deletePostMutation',
  })
)(DeletePostButton)

export default compose(
  graphql(DELETE_POST_MUTATION, { name: 'deletePostMutation' })
)(withRouter(DeletePostWithGraphQL))

자신이 쓴 글만 수정, 삭제 가능하도록 작업

문제 상황

남이 쓴 글인데 수정, 삭제 버튼이...


혼자서 User 를 여러개 생성하고 돌려가며 CRUD 테스트를 하다보니 이런 기초적인 실수도 눈치를 채지 못했습니다. 얼른 고쳐봅시다! 간단히 Post.author.idloggedInUser.id를 비교해 같을 때만 수정, 삭제 버튼을 보여주면 되겠습니다.

// src/components/PostDetail.js

import { loggedInUserQuery, postQuery } from '../queries/queries'; // 추가

class PostDetail extends React.Component {
   constructor(props) {
     super(props);
   }

   render() {
     // 생략...
     const { loggedInUser } = this.props.loggedInUserQuery;

     return (
        <article className="baskerville pb5" style={{ marginTop: '10px' }}>
          <Link to="/" style={{ margin: '10px' }}>
            // 생략...
          </Link>

          {loggedInUser
          ? Post.author.id === loggedInUser.id && (
              <span>
                <UpdatePostButton />
                <DeletePostButton />
              </span>
            )
          : null}

          // 생략...
     )
   }
}

export default compose(
  graphql(loggedInUserQuery, {
    name: 'loggedInUserQuery',
  }),
  graphql(postQuery, {
    name: 'postQuery',
    options: ({ match }) => ({
      variables: {
        id: match.params.id,
      },
    }),
  })
)(withRouter(PostDetail));

Form validation

게시물 생성 시 validation 과정이 없어서 제목을 넣지 않고도 글 작성이 가능했습니다 😅 titlecontent, imageUrl Form을 검증하여 제목은 2 자 이상, 내용은 5 자 이상, 그리고 이미지 URL 은 jpeg|jpg|gif|png 확장자로 끝날 때에만 게시물 생성이 가능하도록 고쳐봅시다!

// src/components/CreatePost.js

import { Form, Icon, Input, Button } from 'antd' // 추가

const FormItem = Form.Item

class CreatePost extends React.Component {
  constructor(props) {
    super()
    // 생략...
  }

  componentDidMount() {
    this.props.form.validateFields()
  }

  render() {
    const {
      getFieldDecorator,
      getFieldsError,
      getFieldError,
      isFieldTouched,
    } = this.props.form

    const formItemLayout = {
      labelCol: {
        xs: { span: 24 },
        sm: { span: 8 },
      },
      wrapperCol: {
        xs: { span: 24 },
        sm: { span: 16 },
      },
    }

    // 생략...

    return (
      <Form>
        <div className="w-100 pa4 flex justify-center">
          <div style={{ maxWidth: 800 }} className="w-100 pa3 mv2">
            <FormItem>
              {getFieldDecorator('title', {
                rules: [
                  {
                    min: 2,
                    message: '제목을 2자 이상 입력해주세요',
                  },
                ],
              })(
                <input
                  className="w-100 pa3 mv2"
                  value={this.state.title}
                  placeholder="제목"
                  onChange={e => this.setState({ title: e.target.value })}
                />
              )}
            </FormItem>
            <FormItem>
              {getFieldDecorator('content', {
                rules: [
                  {
                    min: 5,
                    message: '내용을 5자 이상 입력해주세요',
                  },
                ],
              })(
                <textarea
                  style={{ height: 300 }}
                  className="w-100 pa3 mv2"
                  value={this.state.content}
                  placeholder="내용"
                  onChange={e => this.setState({ content: e.target.value })}
                />
              )}
            </FormItem>
            <FormItem>
              {getFieldDecorator('url', {
                rules: [
                  {
                    pattern: /\.(jpeg|jpg|gif|png)$/,
                    message: '유효한 이미지 url을 입력해주세요.',
                  },
                ],
              })(
                <input
                  className="w-100 pa3 mv2"
                  value={this.state.imageUrl}
                  placeholder="이미지 URL"
                  onChange={e => this.setState({ imageUrl: e.target.value })}
                />
              )}
            </FormItem>

            {this.state.imageUrl && (
              <img src={this.state.imageUrl} alt="" className="w-100 mv3" />
            )}

            {this.state.title.length > 1 &&
              this.state.content.length > 4 &&
              this.state.imageUrl.match(/\.(jpeg|jpg|gif|png)$/) && (
                <button
                  className="pa3 bg-black-10 bn dim ttu pointer"
                  onClick={this.handlePost}
                >
                  작성완료
                </button>
              )}
          </div>
        </div>
      </Form>
    )
  }
}

const WrappedCreatePostForm = Form.create()(CreatePost)
// 생략...

export default compose(
  graphql(createPostMutation, {
    name: 'createPostMutation',
  }),
  graphql(loggedInUserQuery, {
    name: 'loggedInUserQuery',
    options: { fetchPolicy: 'network-only' },
  })
)(withRouter(WrappedCreatePostForm))

Pagination

지금은 ListPage 한 페이지에서 길게 게시물 목록이 주르륵 나열됩니다. 이를 일반 게시판에서 게시물이 10 개 단위로 Pagination이 되는 것과 같이 고쳐봅시다!

Pagination

antd Table 옵션으로 pagination 한 줄 추가


마무리

아인슈타인 명언

아인슈타인의 명언...


“만약 당신이 무엇인가를 쉽게 설명할 수 없다면, 그것을 제대로 이해한 것이 아니다.” 이번 시리즈를 계기로 단순히 그냥 돌아가도록 만드는게 아니라 누군가가 이해할 수 있도록 설명하면서 개발하는건 참 어렵다는 걸 알게되었습니다. 생각해보면 협업을 위해서는 남들이 보고 쉽게 읽을 수 있는 코드를 작성해야 하는데, 리팩토링을 하려고 제 코드를 보는데도 과거의 저를 패고 싶은 마음이 드니… GraphQL 로 게시판 만들기 시리즈는 여기서 마무리하도록 하겠습니다 🤯

계속해서 GraphQL 을 공부하면서 블로그 글로 연재는 하지않더라도 이 graphql-board는 계속해서 발전시켜 나가도록 해보겠습니다. 그동안 노잼 튜토리얼 시리즈를 읽어주셔서 감사드리고 더 강해져서 돌아오도록 하겠습니다!! 🏃‍

Github 저장소

Published 9 Jun 2018

I'm interested in React, GraphQL.