Post type fields
지난 글에서는 정말 간단하게 게시판의 CRUD
를 구현해보았습니다. 이번에는 GraphQL 로 게시판 만들기 시리즈 마지막 글인만큼 리팩토링을 통해 조금은 더 완성도 있는 게시판을 만들어보도록 하겠습니다.
수정, 보완할 점들을 나열해보고 하나씩 차근차근 고쳐보도록 하겠습니다.
하… 고쳐야 할 게 산더미네요; 한 번 달려봅시다!
여러 Component 에서 자주 쓰이는 loggedInUserQuery
와 postQuery
, 그리고 allPostsQuery
를
queries 디렉토리에 따로 정리해두고 각 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
들을 여러 곳에서 재활용하면 됩니다.
src/components/App.js
src/components/ListPage.js
src/components/CreateUser.js
src/components/LoginUser.js
지금까지는 게시물을 작성하고 나서 ListPage 로 리다이렉트된 이후 새로고침을 해줘야만
새롭게 생성된 Post 가 게시판에 반영되었습니다. 이는 정말 간단하게 mutation에 refetchQueries
로 allPostsQuery
를 넘겨주면 해결할 수 있습니다.
src/components/CreatePost.js
처음에는 단순히 게시물의 내용을 보여주려는 상세 페이지로서 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))
수정, 삭제하는 로직은 각각 UpdatePostButton
과 DeletePostButton
에서 처리해줍니다.
// 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.id
와 loggedInUser.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));
게시물 생성 시 validation
과정이 없어서 제목을 넣지 않고도 글 작성이 가능했습니다 😅
title
과 content
, 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))
지금은 ListPage
한 페이지에서 길게 게시물 목록이 주르륵 나열됩니다. 이를
일반 게시판에서 게시물이 10 개 단위로 Pagination
이 되는 것과 같이 고쳐봅시다!
antd Table 옵션으로 pagination 한 줄 추가
아인슈타인의 명언...
“만약 당신이 무엇인가를 쉽게 설명할 수 없다면, 그것을 제대로 이해한 것이 아니다.” 이번 시리즈를 계기로 단순히 그냥 돌아가도록 만드는게 아니라 누군가가 이해할 수 있도록 설명하면서 개발하는건 참 어렵다는 걸 알게되었습니다. 생각해보면 협업을 위해서는 남들이 보고 쉽게 읽을 수 있는 코드를 작성해야 하는데, 리팩토링을 하려고 제 코드를 보는데도 과거의 저를 패고 싶은 마음이 드니… GraphQL 로 게시판 만들기 시리즈는 여기서 마무리하도록 하겠습니다 🤯
계속해서 GraphQL 을 공부하면서 블로그 글로 연재는 하지않더라도 이 graphql-board
는 계속해서 발전시켜 나가도록 해보겠습니다. 그동안 노잼 튜토리얼 시리즈를 읽어주셔서 감사드리고 더 강해져서 돌아오도록 하겠습니다!! 🏃