Blogrow Project Day 05

thisisyjin·2022년 5월 22일
0

Dev Log 🐥

목록 보기
16/23

Blogrow Project

📝 DAY 05 - 220522 (END)

  • 포스트 수정 / 삭제 기능 구현
  • 프로젝트 마무리

포스트 수정

PostActionButtons 컴포넌트 생성

  • 포스트 작성자에게만 '수정' or '삭제' 버튼이 렌더링 되도록.
  • user의 id와 posts의 user의 id가 같은지 확인하는 로직이 필요.

components/post/PostActionButtons.js

import styled from 'styled-components';
import palette from '../../lib/styles/palette';

const PostActionButtonsBlock = styled.div`
  display: flex;
  justify-content: flex-end;
  margin-bottom: 2rem;
  margin-top: -1.5rem;
`;

const ActionButton = styled.button`
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  color: ${palette.gray[6]};
  font-weight: 800;
  border: none;
  outline: none;
  font-size: 0.875rem;
  cursor: pointer;
  &:hover {
    background: ${palette.gray[1]};
    color: ${palette.teal[7]};
  }
  & + & {
    margin-left: 0.25rem;
  }
`;

const PostActionButtons = () => {
  return (
    <PostActionButtonsBlock>
      <ActionButton>수정</ActionButton>
      <ActionButton>삭제</ActionButton>
    </PostActionButtonsBlock>
  );
};

export default PostActionButtons;

PostViewer의 PostHead 하단에 버튼이 렌더링되어야 함.
그러나 직접 PostViewer에 렌더링 시 props가 불필요하게 PostViewer에 전달될 수 있음.

✅ 해결방법

  1. PostActionButtons의 컨테이너를 만들어 PostViewer에 렌더링.
  2. PostViewer의 Props로 jsx를 직접 전달해줌.
    -> props로 JSX(=컴포넌트)를 직접 전달해줄 수 있다.

컨테이너를 만들 필요가 없고, PostViewerContainer만 수정하면 되는 2번째 방법으로 구현할 것임.

PostViewContainer 수정

// 🔻 PostActionButtons 임포트
import PostActionButtons from '../../components/post/PostActionButtons';

const PostViewerContainer = () => {
  	... 
  return (
    <PostViewer
      post={post}
      loading={loading}
      error={error}
      // 🔻 actionButtons 라는 props로 컴포넌트 자체를 전달해줌.
      actionButtons={<PostActionButtons />}
    />
  );
};

export default PostViewerContainer;

PostViewer 컴포넌트 수정

// 🔻 actionButtons 라는 props를 받아옴
const PostViewer = ({ post, error, loading, actionButtons }) => {
  ...
return (
    <PostViewerBlock>
      <PostHead>
        <h1>{title}</h1>
        <SubInfo
          username={user.username}
          publishedDate={publishedDate}
          hasMarginTop
        />
        <Tags tags={tags} />
      </PostHead>
    // 🔻 actionButtons는 JSX 코드, 즉 값이므로 아래와 같이 렌더링 가능.
      {actionButtons}
      <PostContent dangerouslySetInnerHTML={{ __html: body }} />
    </PostViewerBlock>
  );
};

export default PostViewer;

TEST

  • actionButtons props가 잘 렌더링 되어있음.

수정 버튼 클릭시 글쓰기 페이지로 이동

  • 수정 버튼 클릭시 글쓰기 페이지로 이동하고, 현재 보고있는 포스트가 나타나게.
  • write 모듈 수정
    -> SET_ORIGINAL_POST 액션 추가. (기존 포스트 설정)

write 모듈 수정

modules/write.js 수정

// 🔻 Type 생성
const SET_ORIGINAL_POST = 'write/SET_ORIGINAL_POST';

// 🔻 액션 생성함수 
export const setOriginalPost = createAction(SET_ORIGINAL_POST, (post) => post);


const initialState = {
  title: '',
  body: '',
  tags: [],
  post: null,
  postError: null,
  // 🔻 state 추가
  originalPostId: null,
};

const write = handleActions(
  {
    	...
    [SET_ORIGINAL_POST]: (state, { payload: post }) => ({
      ...state,
      title: post.title,
      body: post.body,
      tags: post.tags,
      originalPostId: post._id,
    }),
  },
  initialState,
);

export default write;

PostViewContainer 수정

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from '../../components/post/PostViewer';
import { useParams, useNavigate } from 'react-router-dom';
import PostActionButtons from '../../components/post/PostActionButtons';
// 🔻 setOriginalPost 불러옴
import { setOriginalPost } from '../../modules/write';

const PostViewerContainer = () => {
  const { postId } = useParams();
  const dispatch = useDispatch();
  const navigate = useNavigate();

  // 🔻 state.user의 user필드를 가져옴
  const { post, error, loading, user } = useSelector(
    ({ post, loading, user }) => ({
      post: post.post,
      error: post.error,
      loading: loading['post/READ_POST'],
      user: user.user, // 로그인 상태 (user)
    }),
  );

  useEffect(() => {
    dispatch(readPost(postId)); // readPost에 postId(payload)를 전달하여 API 호출
    return () => {
      dispatch(unloadPost());
    }; // return 안에는 정리함수 (componentWillUnmount)
  }, [dispatch, postId]);

  // 🔻 setOriginalPost를 해서 현재 글의 상태(post.post)를 그대로 가져오고 write로 이동
  const onEdit = () => {
    dispatch(setOriginalPost(post));
    navigate('/write');
  };

  // 🔻 로그인상태(user._id) 와 글 작성자(post.user._id)가 일치하는지 확인.
  const ownPost = (user && user._id) === (post && post.user._id);

  return (
    <PostViewer
      post={post}
      loading={loading}
      error={error}
      // 🔻 ownPost가 true면 (작성자라면) onEdit함수를 전달하여 JSX 전달.
      actionButtons={ownPost && <PostActionButtons onEdit={onEdit} />}
    />
  );
};

export default PostViewerContainer;

PostActionButtons 컴포넌트 수정

  • PostActionButtons에서 props로 받은 onEdit을 호출하도록 함.
	...
const PostActionButtons = ({ onEdit }) => {
  return (
    <PostActionButtonsBlock>
      // 🔻 수정 버튼 클릭시 onEdit이 실행되도록
      <ActionButton onClick={onEdit}>수정</ActionButton>
      <ActionButton>삭제</ActionButton>
    </PostActionButtonsBlock>
  );
};

export default PostActionButtons;

OnEdit 실행 -> dispatch(setOriginalPost(post)) -> SET_ORIGINAL_POST 액션 디스패치 -> post.title,post.body, post.tags, post._id 를 write의 state에 저장함
-> /write로 이동 -> 저장된 state대로 글 작성

Editor 컴포넌트 수정

  • 에디터에서 내용의 초기값이 설정되도록 해야 함.
    -> props로 받은 bodyquillInstance.current.root.innerHTML 에 넣어줌.

components/wrtie/Editor.js 수정

	...
    
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;  // body가 변경되어도 처음에만 불러오도록 
    mounted.current = true;  
    quillInstance.current.root.innerHTML = body;
  }, [body]);
  • body 값은 내용을 입력할 때 마다 변경된다.
  • 따라서 위 함수에서 body가 변경될 때 마다 useEffect가 실행되는데, 우리는 맨 처음에만
    body를 불러와야 한다.
  • useRef를 변수처럼 사용하여 mounted라는 값을 이용해 첫번째에만 실행되게 한다.

deps를 [ ]로 작성해도 되지만,
ESLint 규칙은 useEffect에서 사용되는 모든 외부 값을 deps에 넣어줘야 한다.
-> 이렇게 하려면 주석으로 /* eslint-disable-line */ 을 넣어주자.

Result

  • 수정 버튼을 누르면 위와 같이 body와 title, tags 모두 잘 불러온다.

    -> 리덕스 스토어의 write 에도 잘 들어가 있다.

update API

updatePost API 추가

lib/api/posts.js 수정

import client from './client';

export const writePost = ({ title, body, tags }) =>
  client.post('/api/posts', { title, body, tags });

export const readPost = (id) => client.get(`/api/posts/${id}`);

export const listPosts = ({ page, username, tag }) => {
  return client.get('/api/posts', { params: { page, username, tag } });
};

// 🔻 updatePost API 추가
export const updatePost = ({ id, title, body, tags }) =>
  client.patch(`api/posts/${id}`, { title, body, tags });

write 모듈 수정

  • 액션 생성
    UPDATE_POST (UPDATE_POST_SUCCESS, UPDATE_POST_FAILURE)

  • saga 생성
    updatePostSaga

modules/write.js


const [UPDATE_POST, UPDATE_POST_SUCCESS, UPDATE_POST_FAILURE] =
  createRequestActionTypes('write/UPDATE_POST');

export const updatePost = createAction(
  UPDATE_POST,
  ({ id, title, body, tags }) => ({
    id,
    title,
    body,
    tags,
  }),
);

// saga 생성

const writePostSaga = createRequestSaga(WRITE_POST, postAPI.writePost);
const updatePostSaga = createRequestSaga(UPDATE_POST, postAPI.updatePost);
export function* writeSaga() {
  yield takeLatest(WRITE_POST, writePostSaga);
  yield takeLatest(UPDATE_POST, updatePostSaga);
}

const write = handleActions(
  {
    	...
    [UPDATE_POST_SUCCESS]: (state, { payload: post }) => ({
      ...state,
      post,
    }),
    [UPDATE_POST_FAILURE]: (state, { payload: postError }) => ({
      ...state,
      postError,
    }),
  },
  initialState,
);

export default write;

WriteActionButtonContainer 수정

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import WriteActionButtons from '../../components/write/WriteActionButtons';
import { updatePost, writePost } from '../../modules/write';
import { useNavigate } from 'react-router-dom';

const WriteActionButtonsContainer = () => {
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const { title, body, tags, post, postError, originalPostId } = useSelector(
    ({ write }) => ({
      title: write.title,
      body: write.body,
      tags: write.tags,
      post: write.post,
      postError: write.postError,
      // 🔻 state.write.originalPostId 가져옴
      originalPostId: write.originalPostId,
    }),
  );

  const onPublish = () => {
    if (originalPostId) {
      // 🔻 수정하는 것이라면 - originalPostId가 존재 - updatePost 디스패치
      dispatch(updatePost({ title, body, tags, id: originalPostId }));
      return;
    }
    dispatch(writePost({ title, body, tags }));
  };

  ...

  return (
    <WriteActionButtons
      onPublish={onPublish}
      onCancel={onCancel}
      // 🔻 originalPostId가 true면 isEdit=true
      isEdit={!!originalPostId}
    />
  );
};

export default WriteActionButtonsContainer;

WriteActionButton 수정

...

const WriteActionButtons = ({ onPublish, onCancel, isEdit }) => {
  return (
    <WriteActionButtonsBlock>
      <StyledButton teal onClick={onPublish}>
        // 🔻 isEdit이 true면 (=originalPostId 존재시) - 포스트 수정
        포스트 {isEdit ? '수정' : '등록'}
      </StyledButton>
      <StyledButton onClick={onCancel}>취소</StyledButton>
    </WriteActionButtonsBlock>
  );
};


-> 포스트 수정이라고 나옴.

Result

-> 태그와 본문 모두 잘 수정되었음.

+) 🔻 참고로, 남이 쓴 글은 수정/삭제 버튼이 안보임!


포스트 삭제

  • 삭제버튼을 누르면 모달을 띄워 다시한번 삭제 여부를 묻는 기능 추가.
    -> Modal 컴포넌트 생성 필요.

components/common/AskModal.js 생성

import styled from 'styled-components';
import Button from './Button';

const Fullscreen = styled.div`
  position: fixed;
  z-index: 30;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.25);
  display: flex;
  justify-content: center;
  align-items: center;
`;

const AskModalBlock = styled.div`
  width: 320px;
  background: #fff;
  padding: 1.5rem;
  border-radius: 4px;
  box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.125);
  h2 {
    margin-top: 0;
    margin-bottom: 1rem;
  }
  p {
    margin-bottom: 3rem;
  }
  .buttons {
    display: flex;
    justify-content: flex-end;
  }
`;

const StyledButton = styled(Button)`
  height: 2rem;
  & + & {
    margin-left: 0.75em;
  }
`;

const AskModal = ({
  title,
  description,
  visible,
  confirmText = '확인',
  cancelText = '취소',
  onConfirm,
  onCancel,
}) => {
  if (!visible) return null;
  return (
    // 🔻 Fullscreen이 최상위에 있어야 함.
    <Fullscreen>
      <AskModalBlock>
        <h2>{title}</h2>
        <p>{description}</p>
        <div className="buttons">
          <StyledButton onClick={onCancel}>{cancelText}</StyledButton>
          <StyledButton onClick={onConfirm}>{confirmText}</StyledButton>
        </div>
      </AskModalBlock>
    </Fullscreen>
  );
};

export default AskModal;

FullScreen 컴포넌트

position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;

-> 전체 화면을 다 채우게. (position: fixed)

display: flex;
align-items: center;
justify-content: center;

-> 자식요소(AskModalBlock)를 정 가운데에 배치함.

visible

visible props를 true/false로 조절하여 AskModal을 보이게 할지, 안보이게 할지 조정함.
(모달 닫기/열기)


AskRemoveModal 컴포넌트

components/post/AskRemoveModal.js 생성

import AskModal from '../common/AskModal';

const AskRemoveModal = ({ visible, onConfirm, onCancel }) => {
  return (
    <AskModal
      visible={visible}
      title="포스트 삭제"
      description="포스트를 정말 삭제하시겠습니까?"
      confirmText="삭제"
      onConfirm={onConfirm}
      onCancel={onCancel}
    />
  );
};

export default AskRemoveModal;
  • 나중에 다른 모달이 생겨도 관리하기 쉽도록 별도의 모달 컴포넌트를 만들어주고, props를 알맞게 전달해줌.

PostActionButtons 수정

import { useState } from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import AskRemoveModal from './AskRemoveModal';

	...

const PostActionButtons = ({ onEdit, onRemove }) => {
  const [modal, setModal] = useState(false);
  const onRemoveClick = () => {
    setModal(true);
  };
  const onCancel = () => {
    setModal(false);
  };
  const onConfirm = () => {
    setModal(false);
    onRemove();
  };

  return (
    <>
      <PostActionButtonsBlock>
        <ActionButton onClick={onEdit}>수정</ActionButton>
        <ActionButton onClick={onRemoveClick}>삭제</ActionButton>
      </PostActionButtonsBlock>
      <AskRemoveModal
        visible={modal}
        onCancel={onCancel}
        onConfirm={onConfirm}
      />
    </>
  );
};

export default PostActionButtons;

-> 삭제 버튼 클릭시 모달이 나타남.


onRemove 구현

removePost API

lib/api/posts.js 수정

...

export const removePost = (id) => client.delete(`api/posts/${id}`);

PostViewerContainer 수정

  • onRemove 함수를 만들어 removePost API를 호출하도록.
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from '../../components/post/PostViewer';
import { useParams, useNavigate } from 'react-router-dom';
import { setOriginalPost } from '../../modules/write';
import PostActionButtons from '../../components/post/PostActionButtons';
import { removePost } from '../../lib/api/posts';

const PostViewerContainer = () => {
  const { postId } = useParams();
  const dispatch = useDispatch();
  const navigate = useNavigate();

  const { post, error, loading, user } = useSelector(
    ({ post, loading, user }) => ({
      post: post.post,
      error: post.error,
      loading: loading['post/READ_POST'],
      user: user.user, // 로그인 상태 (user)
    }),
  );

  useEffect(() => {
    dispatch(readPost(postId)); // readPost에 postId(payload)를 전달하여 API 호출
    return () => {
      dispatch(unloadPost());
    }; // return 안에는 정리함수 (componentWillUnmount)
  }, [dispatch, postId]);

  const onEdit = () => {
    dispatch(setOriginalPost(post));
    navigate('/write');
  };

  // 🔻 removePost API는 postId만 전달해주면 되므로 바로 컨테이너에서 호출함. 
  const onRemove = async () => {
    try {
      await removePost(postId); // useParams로 받은 postId
      navigate('/'); // 삭제 후 홈으로 이동.
    } catch (e) {
      console.log(e);
    }
  };

  const ownPost = (user && user._id) === (post && post.user._id);

  return (
    <PostViewer
      post={post}
      loading={loading}
      error={error}
      actionButtons={
        // 🔻 PostActionButtons에 onRemove 함수 전달
        ownPost && <PostActionButtons onEdit={onEdit} onRemove={onRemove} />
      }
    />
  );
};

export default PostViewerContainer;
  • removePost 함수의 경우에는 PostId만 넘겨주면 알아서 삭제가 된다.
  • 다른 API는 모듈 파일에 불러와서 saga에서 비동기 처리를 해줬다.
    -> createRequestSaga의 두번째 인자로 API.함수를 넣어서 성공/실패 액션을 실행했음.

Result


meta 태그 설정

  • 검색 엔진 최적화 (SEO)를 위한 작업.
  • 검색 엔진에서 웹페이지 수집 시 meta 태그를 읽는다.

react-helmet-async

$ yarn add react-helmet-async

HelmetProvider 컴포넌트

src/index.js 파일 수정

import { HelmetProvider } from 'react-helmet-async';

...

root.render(
  <Provider store={store}>
    <BrowserRouter>
      <HelmetProvider>
        <App />
      </HelmetProvider>
    </BrowserRouter>
  </Provider>,
);

-> HelmetProvider로 App 컴포넌트를 감싸줌.

Helmet 컴포넌트

  • meta 태그를 설정하고 싶은 곳에 <Helmet> 컴포넌트를 사용하면 됨.

src/App.js 수정

import { Routes, Route } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';

import LoginPage from './pages/LoginPage';
import WritePage from './pages/WritePage';
import PostListPage from './pages/PostListPage';
import PostPage from './pages/PostPage';
import RegisterPage from './pages/RegisterPage';

function App() {
  return (
    <>
      <Helmet>
        <title>BLOGROW🌱</title>
      </Helmet>
      <Routes>
        <Route path="/" element={<PostListPage />} />
        <Route path="/login" element={<LoginPage />} />
        <Route path="/register" element={<RegisterPage />} />
        <Route path="/write" element={<WritePage />} />
        <Route path="/@:username">
          <Route index element={<PostListPage />} />
          <Route path=":postId" element={<PostPage />} />
        </Route>
      </Routes>
    </>
  );
}

export default App;
  • 브라우저 페이지 제목이 변경됨.

Helmet은 더 깊숙한 곳에 위치한 Helmet이 우선권을 차지함.
-> App보다 WritePage가 더 깊숙한 곳이므로 WritePage에서 설정한 title 값이 나타남.

WritePage.js 수정

pages/WritePage.js 수정

import Responsive from '../components/common/Responsive';
import EditorContainer from '../containers/write/EditorContainer';
import TagBoxContainer from '../containers/write/TagBoxContainer';
import WriteActionButtonsContainer from '../containers/write/WriteActionButtonsContainer';
import { Helmet } from 'react-helmet-async';

const WritePage = () => {
  return (
    <Responsive>
      <Helmet>
        <title>글 작성하기 - BLOGROW </title>
      </Helmet>
      <EditorContainer />
      <TagBoxContainer />
      <WriteActionButtonsContainer />
    </Responsive>
  );
};

export default WritePage;

PostViewer.js 수정

  • 변수(state,props)를 제목에 넣을 수도 있음.
import { Helmet } from 'react-helmet-async';

return (
    <PostViewerBlock>
      <Helmet>
        <title>{title} - BLOGROW</title>
      </Helmet>
    
      <PostHead>
        ...
  );

-> post.title이 들어감.


프로젝트 빌드

yarn build

  • 프론트엔드 영역에서 build를 해줌.
$ yarn build

  • build 디렉터리가 생성됨.

koa-static

  • build 디렉터리 내의 파일을 사용할 수 있도록 하기 위해
    koa-static 라이브러리로 정적 파일 제공 기능을 구현.

  • 백엔드(서버) 터미널에서 진행.

$ yarn add koa-static

src/main.js 수정

백엔드의 src/main.js 수정

require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';
// 🔻 koa-static의 serve 함수
import serve from 'koa-static';
import path from 'path';
// 🔻 koa-send의 send 함수
import send from 'koa-send';

import api from './api';
import jwtMiddleware from './lib/jwtMiddleware';

// process.env 내부 값에 대한 레퍼런스
const { PORT, MONGO_URI } = process.env;

// mongoDB와 연결
mongoose
  .connect(MONGO_URI)
  .then(() => {
    console.log('Connected to MongoDB');
  })
  .catch((e) => {
    console.error(e);
  });

const app = new Koa();
const router = new Router();

router.use('/api', api.routes()); // api 라우트 적용

// 라우터 적용 전에 미들웨어를 적용해야 함
app.use(bodyParser());
app.use(jwtMiddleware);

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

// 🔻 추가된 부분.

// 1. ../../blog-frontend/build 디렉터리
const buildDirectory = path.resolve(__dirname, '../../blog-frontend/build');

// .2 serve함수로 ../../blog-frontend/build 디렉터리를 사용할 수 있게 함
app.use(serve(buildDirectory));

// 3. 404 에러 & /api 로 시작하지 않는 경우 
app.use(async (ctx) => {
  if (ctx.status === 404 && ctx.path.indexOf('/api') !== 0) {
    // index.html 내용 반환 - 미들웨어
    await send(ctx, 'index.html', { root: buildDirectory });
  }
});

const port = PORT || 4000;
app.listen(port, () => {
  console.log('Listening to port ' + port);
});

send 함수 - 미들웨어 작성

  • 클라이언트 기반 라우팅이 작동하게 해줌.
    이 미들웨어를 적용하지 않으면 url에 직접 localhost:4000/write를 입력해서 들어갈 경우, 404 에러가 발생함.
    -> 즉, 서버측과 클라이언트측 url을 연동시켜주는 역할.


추가 작업

서버 호스팅

  • 로컬 환경이 아닌 다른 사람도 사용하게 하기 위해.
  • AWS, Vultr 등이 있음.

서버사이드 렌더링

  • axios 인스턴스인 client.js에 baseURL을 설정해줌.
import client from './lib/api/client';
client.defaults.baseURL = 'http://localhost:4000';
  • 두 종류의 서버를 구동해야 함.
  1. API 서버(:4000)
  2. 서버사이드 렌더링 전용 서버
  • nginx를 사용하여 사용자가 요청한 경로에 따라 다른 서버에서 처리하게 하면 됨.

  • nginx를 사용하면 정적 파일제공을 nginx가 자체적으로 하는 것이 더 빠름.


리액트 프로젝트 순서

1. 기능 설계

  • 어떤 컴포넌트가 필요할지

2. UI 만들기

  • 사용자에게 보이는 UI를 먼저 생성

3. API 연동

  • 미들웨어(redux-saga)등 사용

4. 상태관리

  • 리덕스 / state, props 등으로 상태 관리
  • 필요시 컨테이너를 새로 생성함.

5. 성능 최적화

  • React.memo 또는 useCallback, useMemo 등을 잘 사용.
profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글