Blogrow Project Day 03

thisisyjin·2022년 5월 20일
0

Dev Log 🐥

목록 보기
13/23

Blogrow Project

📝 DAY 03 - 220520

  • 글쓰기 기능 구현
  • quill 라이브러리 (에디터)
  1. 에디터 UI 구현
  2. 리덕스 상태관리
  3. API 연동

에디터 UI 구현

quill 라이브러리

$ yarn add quill

Editor 컴포넌트 생성

components/write/Editor.js

import { useRef, useEffect } from 'react';
import Quill from 'quill';
import 'quill/dist/quill.bubble.css';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';

const EditorBlock = styled(Responsive)`
  padding-top: 5rem;
  padding-bottom: 5rem;
`;

const TitleInput = styled.input`
  font-size: 3rem;
  outline: none;
  padding-bottom: 0.5rem;
  border: none;
  border-bottom: 1px solid ${palette.gray[4]};
  margin-bottom: 2rem;
  width: 100%;
`;

const QuillWrapper = styled.div`
  .ql-editor {
    padding: 0;
    min-height: 320px;
    font-size: 1.125 rem;
    line-height: 1.5;
  }

  .ql-editor.ql-blank::before {
    left: 0px;
  }
`;

const Editor = () => {
  const quillElement = useRef(null);
  const quillInstance = useRef(null);

  useEffect(() => {
    quillInstance.current = new Quill(quillElement.current, {
      theme: 'bubble',
      placeholder: '내용을 입력하세요.',
      modules: {
        toolbar: [
          [{ header: '1' }, { header: '2' }],
          ['bold', 'italic', 'underline', 'strike'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          ['blockquote', 'code-block', 'link', 'image'],
        ],
      },
    });
  }, []);
  return (
    <EditorBlock>
      <TitleInput placeholder="제목을 입력하세요" />
      <QuillWrapper>
        <div ref={quillElement} />
      </QuillWrapper>
    </EditorBlock>
  );
};

export default Editor;

WritePage 렌더링

pages/WritePage 수정

import Editor from '../components/write/Editor';
import Responsive from '../components/common/Responsive';

const WritePage = () => {
  return (
    <Responsive>
      <Editor />
    </Responsive>
  );
};

export default WritePage;

Editor 컴포넌트 수정

...

const Editor = () => {
  // 🔻 에디터를 적용할 엘리먼트 지정
  const quillElement = useRef(null);
  // 🔻 Quill 인스턴스 지정 (변수 느낌임)
  const quillInstance = useRef(null);

  useEffect(() => {
    // new Quill(DOM, { theme, placeholder, modules(.toolbar) });
    quillInstance.current = new Quill(quillElement.current, {
      theme: 'bubble',
      placeholder: '내용을 입력하세요.',
      modules: {
        toolbar: [
          [{ header: '1' }, { header: '2' }],
          ['bold', 'italic', 'underline', 'strike'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          ['blockquote', 'code-block', 'link', 'image'],
        ],
      },
    });
  }, []);
  return (
    <EditorBlock>

      <TitleInput placeholder="제목을 입력하세요" />
      <QuillWrapper>
          { /* 🔻 quill을 적용할 div를 담음 */ }
        <div ref={quillElement} />
      </QuillWrapper>
    </EditorBlock>
  );
};

export default Editor;
  • new Quill()을 해서 에디터를 불러옴
    -> 첫번째 인자 = quillElement.current(=DOM 위치)
    -> 두번째 인자 = 객체 (theme, placeholder, modules 필드 존재)
    -> modulestoolbar - 문서 참조

🔺 modules.toolbar에 등록한 것이 툴바에 구현된다.
(header / bold,italic... / list / blockquote 등)

-> placeholder 잘 적용됨.
-> 에디터의 기능을 구현한 예.


에디터 하단 컴포넌트 UI

  1. 태그 추가
  2. 포스트 작성 버튼
  3. 취소 버튼

TagBox 컴포넌트

components/write/TagBox.js 생성

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

// 🔻 전체를 감싸줌 + h4 스타일링
const TagBoxBlock = styled.div`
  width: 100%;
  border-top: 1px solid ${palette.gray[2]};
  padding-top: 2rem;

  h4 {
    color: ${palette.gray[8]};
    margin-top: 0;
    margin-bottom: 0.5rem;
  }
`;

// 🔻 form 태그 수식. (input, button 리셋 CSS)
const TagForm = styled.form`
  border-radius: 4px;
  overflow: hidden;
  display: flex;
  width: 256px;
  border: 1px solid ${palette.gray[9]};

  /* reset CSS */
  input,
  button {
    outline: none;
    border: none;
    font-size: 1rem;
  }

  input {
    padding: 0.5rem;
    flex: 1;
    min-width: 0;
  }

  button {
    cursor: pointer;
    padding-right: 1rem;
    padding-left: 1rem;
    border: none;
    background: ${palette.gray[8]};
    color: #fff;
    font-weight: 800;
    &:hover {
      background: ${palette.gray[6]};
    }
  }
`;

// 🔻 TagItem을 구성하는 각각의 Tag를 의미
const Tag = styled.div`
  margin-right: 0.5rem;
  color: ${palette.gray[6]};
  cursor: pointer;
  &:hover {
    opacity: 0.5;
  }
`;

// 🔻 태그 리스트 컨테아너. (display:flex로 좌우정렬)
const TagListBlock = styled.div`
  display: flex;
  margin-top: 0.5rem;
`;
 
// 🔻 Tag 컴포넌트를 React.memo 해주기 위한 컴포넌트 / tagList에서 tags.map()을 할때 각 요소.
const TagItem = memo(({ tag }) => <Tag>#{tag}</Tag>);
// tag가 바뀔 때만 리렌더링 되도록 처리 (memo 내부 콜백의 인자 -> deps)

// 🔻 tags.map을 해서 (props로 받은)tags 각각의 요소를 TagItem 으로 렌더링 + TagListBlock으로 감싸줌 
const TagList = memo(({ tags }) => (
  <TagListBlock>
    {tags.map((tag) => (
      <TagItem key={tag} tag={tag} />
    ))}
  </TagListBlock>
));
// tags가 바뀔때만 리렌더링 되도록 처리


const TagBox = () => {
  return (
    <TagBoxBlock>
      <h4>태그</h4>
      <TagForm>
        <input placeholder="태그를 입력하세요" />
        <button type="submit">추가</button>
      </TagForm>
      <TagList tags={['태그1', '태그2', '태그3']} />
    </TagBoxBlock>
  );
};

export default TagBox;

❗️ React.memo 는 PureComponent와 동일한 기능.
cf> React.useMemo()는 함수의 값을 기억해두는 Hook임.

  • input이 바뀔때는 input만 리렌더링 되고,
    TagList의 tags가 바뀔땐 TagList만 리렌더링 되도록 컴포넌트를 분리함.
  • React.memo()로 최적화 해줌.

WritePage 렌더링 변경

pages/WritePage.js 수정

import Editor from '../components/write/Editor';
import TagBox from '../components/write/TagBox';
import Responsive from '../components/common/Responsive';

const WritePage = () => {
  return (
    <Responsive>
      <Editor />
      <TagBox />
    </Responsive>
  );
};

export default WritePage;

TagBox 컴포넌트 수정 (useState)

import { useState, useCallback, memo } from 'react';
					
				...
    
const TagItem = memo(({ tag, onRemove }) => (
  <Tag onClick={() => onRemove(tag)}>#{tag}</Tag>
));
// 클릭시 onRemove(tag) -> localTags에서 제거됨

const TagList = memo(({ tags, onRemove }) => (
  <TagListBlock>
    {/* 🔻 tags는 localTags(state)임.*/}
    {tags.map((tag) => (
      <TagItem key={tag} tag={tag} onRemove={onRemove} />
    ))}
  </TagListBlock>
));
// onRemove를 props로 받아서 TagItem에게 다시 전달해줌.

const TagBox = () => {
  // state 추가 
  const [input, setInput] = useState('');
  const [localTags, setLocalTags] = useState([]);

  // form 제출시
  const insertTag = useCallback(
    (tag) => {
      if (!tag) return; // 공백이면
      if (localTags.includes(tag)) return; // 이미 존재하는 태그면
      setLocalTags([...localTags, tag]);
    },
    [localTags],
  );

  // TagItem 클릭시
  const onRemove = useCallback(
    (tag) => {
      setLocalTags(localTags.filter((t) => t !== tag));
    },
    [localTags],
  );

  // input change시
  const onChange = useCallback((e) => {
    setInput(e.target.value);
  }, []);

  const onSubmit = useCallback(
    (e) => {
      e.preventDefault();
      insertTag(input.trim()); // 앞뒤 공백 없앤 후 등록
      setInput(''); // input 초기화
    },
    [input, insertTag],
  );

  return (
    <TagBoxBlock>
      <h4>태그</h4>
      <TagForm onSubmit={onSubmit}>
        <input
          placeholder="태그를 입력하세요"
          onChange={onChange}
          value={input}
        />
        <button type="submit">추가</button>
      </TagForm>
      <TagList tags={localTags} onRemove={onRemove} />
    </TagBoxBlock>
  );
};

export default TagBox;

  • 폼 제출시 - 태그로 등록됨 (렌더링)
  • 태그 클릭시 - 삭제됨

WriteActionButtons 컴포넌트

  • 포스트 작성 / 취소 버튼이 있는 컴포넌트.
  • StyledButton * 2
  • onClick시 포스트 작성 버튼은 onPublish가, 취소 버튼은 onCancel이 실행되도록 함.
    -> (container에게) props로 전달받은 함수.

components/write/WriteActionButtons.js 생성

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

const WriteActionButtonsBlock = styled.div`
  float: right;
  margin-top: 1rem;
  margin-bottom: 3rem;
  button + button {
    margin-left: 0.5rem;
  }
`;

const StyledButton = styled(Button)`
  height: 2.125rem;
  & + & {
    margin-left: 0.5rem;
  }
`;

const WriteActionButtons = ({ onPublish, onCancel }) => {
  return (
    <WriteActionButtonsBlock>
      <StyledButton teal onClick={onPublish}>
        포스트 등록
      </StyledButton>
      <StyledButton onClick={onCancel}>취소</StyledButton>
    </WriteActionButtonsBlock>
  );
};

export default WriteActionButtons;

UI 완성!


리덕스 상태 관리

write 모듈 작성

modules/write.js 생성

import { createAction, handleActions } from 'redux-actions';

const INITIALIZE = 'write/INITIALIZE';
const CHANGE_FIELD = 'write/CHANGE_FIELD';

export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
  key,
  value,
}));

const initialState = {
  title: '',
  body: '',
  tags: [],
};

const write = handleActions(
  {
    [INITIALIZE]: (state) => initialState,
    [CHANGE_FIELD]: (state, { payload: key, value }) => ({
      ...state,
      [key]: value,
    }),
  },
  initialState,
);

export default write;

액션

  1. INITIALIZE = 모든 내용 초기화함
  2. CHANGE_FIELD = 특정 key 값 변경 (key-value 필요)

루트 리듀서 포함

modules/index.js 수정

import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import post, { postSaga } from './post';
import write from './write';

const rootReducer = combineReducers({
  auth,
  loading,
  user,
  post,
  write,
});

export function* rootSaga() {
  yield all([authSaga(), userSaga(), postSaga()]);
}

export default rootReducer;

컨테이너 생성

총 3개의 컨테이너를 생성함.

  • EditorContainer
  • TagBoxContainer
  • WriteActionButtonsContainer

1. EditorContainer 생성

containers/write/EditorContainer.js 생성

import { useEffect, useCallback } from 'react';
import Editor from '../../components/write/Editor';
import { useDispatch, useSelector } from 'react-redux';
import { initialize, changeField } from '../../modules/write';

const EditorContainer = () => {
  const dispatch = useDispatch();
  const { title, body } = useSelector(({ write }) => ({
    title: write.title,
    body: write.body,
  }));

  const onChangeField = useCallback(
    (payload) => dispatch(changeField(payload)),
    [dispatch],
  );

  // 🔻 componentWillUnmount시 초기화 해야함.
  useEffect(() => {
    return () => {
      dispatch(initialize());
    };
  }, [dispatch]);

  return <Editor onChangeField={onChangeField} title={title} body={body} />;
};

export default EditorContainer;

title, body 값을 스토어에서 불러와 (useSelector)
Editor 컴포넌트의 props 로 전달해줌.

에디터에서 값이 바뀔 때 스토어에 값을 저장만 해주고, (=changeField 액션)
스토어 값이 바뀔 때 에디터의 값이 바뀌도록 설정해줘야 함.

❗️ 참고 - Editor 에서 Quill 에디터는 input이 아니므로,onChange, value로 값을 관리할 수 없음.

WritePage.js에서 Editor 대신 EditorContainer을 렌더링 해준다.

import TagBox from '../components/write/TagBox';
import Responsive from '../components/common/Responsive';
import WriteActionButtons from '../components/write/WriteActionButtons';
import EditorContainer from '../containers/write/EditorContainer';

const WritePage = () => {
  return (
    <Responsive>
      <EditorContainer />
      <TagBox />
      <WriteActionButtons />
    </Responsive>
  );
};

export default WritePage;

Editor 컴포넌트 수정

components/wrtie/Editor.js

	...

const Editor = ({ onChangeField, title, body }) => {
  const quillElement = useRef(null);
  const quillInstance = useRef(null);

  useEffect(() => {
    quillInstance.current = new Quill(quillElement.current, {
      theme: 'bubble',
      placeholder: '내용을 작성하세요.',
      modules: {
        toolbar: [
          [{ header: '1' }, { header: '2' }],
          ['bold', 'italic', 'underline', 'strike'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          ['blockquote', 'code-block', 'link', 'image'],
        ],
      },
    });
	// 🔻 quill = 에디터 인스턴스
    const quill = quillInstance.current;
    
    // on 함수 -> text-change / 콜백
    quill.on('text-change', (delta, oldDelta, source) => {
      if (source === 'user') {
        // body 필드 변경 
        onChangeField({ key: 'body', value: quill.root.innerHTML });
      }
    });
  }, [onChangeField]);

  const onChangeTitle = (e) => {
    onChangeField({ key: 'title', value: e.target.value });
  };

  return (
    <EditorBlock>
      <TitleInput
        placeholder="제목을 입력하세요"
        onChange={onChangeTitle}
        value={title}
      />
      <QuillWrapper>
        <div ref={quillElement} />
      </QuillWrapper>
    </EditorBlock>
  );
};

export default Editor;

2. TagBoxContainer 생성

import { useDispatch, useSelector } from 'react-redux';
import TagBox from '../../components/write/TagBox';
import { changeField } from '../../modules/auth';

const TagBoxContainer = () => {
  const dispatch = useDispatch();

  const tags = useSelector((state) => state.write.tags);

  const onChangeTags = (nextTags) => {
    dispatch(changeField({ key: 'tags', value: nextTags }));
  };

  return <TagBox onChangeTags={onChangeTags} tags={tags} />;
};

export default TagBoxContainer;
  • 여기서는 useEffect는 쓰지 X. (onChangeTags는 이벤드핸들러이므로)
    -> 컨테이너에서 useEffect를 쓸때는 ? -> 첫 렌더링시 or willUnmount시에만 실행되어야 할때 사용.
  • WritePage에서 TagBox -> TabBoxContainer로 변경.
import Responsive from '../components/common/Responsive';
import WriteActionButtons from '../components/write/WriteActionButtons';
import EditorContainer from '../containers/write/EditorContainer';
import TagBoxContainer from '../containers/write/TagBoxContainer';

const WritePage = () => {
  return (
    <Responsive>
      <EditorContainer />
      <TagBoxContainer />
      <WriteActionButtons />
    </Responsive>
  );
};

export default WritePage;

TagBox 컴포넌트 수정

...

const TagBox = ({ onChangeTags, tags }) => {
  const [input, setInput] = useState('');
  const [localTags, setLocalTags] = useState([]);

  const insertTag = useCallback(
    (tag) => {
      if (!tag) return; // 공백이면
      if (localTags.includes(tag)) return; // 이미 존재하는 태그면
     // 🔻 onChangeTags에 [...localTags, tag]를 넣어서 액션 디스패치
      const nextTags = [...localTags, tag];
      setLocalTags(nextTags);
      onChangeTags(nextTags);
    },
    [localTags, onChangeTags],
  );

  const onRemove = useCallback(
    (tag) => {
      // 🔻 마찬가지로 onChangeTags로 넣어서 디스패치함
      const nextTags = localTags.filter((t) => t !== tag);
      setLocalTags(nextTags);
      onChangeTags(nextTags);
    },
    [localTags, onChangeTags],
  );

  const onChange = useCallback((e) => {
    setInput(e.target.value);
  }, []);

  // 폼 제출시 -> 1. insertTag   2. setInput('')
  const onSubmit = useCallback(
    (e) => {
      e.preventDefault();
      console.log();
      insertTag(input.trim()); // 앞뒤 공백 없앤 후 등록
      setInput(''); // input 초기화
    },
    [input, insertTag],
  );

  // 🔻 tags(props) 변경시마다 setLocalTags
  useEffect(() => {
    setLocalTags(tags);
  }, [tags]);

  return (
    <TagBoxBlock>
      <h4>태그</h4>
      <TagForm onSubmit={onSubmit}>
        <input
          placeholder="태그를 입력하세요"
          onChange={onChange}
          value={input}
        />
        <button type="submit">추가</button>
      </TagForm>
      <TagList tags={localTags} onRemove={onRemove} />
    </TagBoxBlock>
  );
};

export default TagBox;

  • 리덕스 스토어에도 잘 저장됨.

Posts API 연동

  • 백엔드에서 작성했던 글쓰기 API를 연동.

lib/api/posts.js 생성

import client from './client';

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

write 모듈 수정

  • API를 호출하기 위한 액션과 saga 추가

modules/write.js 수정

import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
  createRequestActionTypes,
} from '../lib/createRequestSaga';
import * as writeAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';

const INITIALIZE = 'write/INITIALIZE';
const CHANGE_FIELD = 'write/CHANGE_FIELD';
// 🔻createRequestActionTypes 모듈로 한꺼번에 액션 타입 생성
const [WRITE_POST, WRITE_POST_SUCCESS, WRITE_POST_FAILURE] =
  createRequestActionTypes('write/WRITE_POST');

export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
  key,
  value,
}));
// 🔻 payload: {title, body, tags}
export const writePost = createAction(WRITE_POST, ({ title, body, tags }) => ({
  title,
  body,
  tags,
}));

// 🔻 saga 생성
const writePostSaga = createRequestSaga(WRITE_POST, writeAPI.writePost);
// WRITE_POST 디스패치시 대신 writePostSaga 실행됨.
export function* writeSaga() {
  yield takeLatest(WRITE_POST, writePostSaga);
}

const initialState = {
  title: '',
  body: '',
  tags: [],
  // 🔻 writePost API 불러온 후 결과에 따라 생성됨.
  post: null,
  postError: null,
};

const write = handleActions(
  {
    [INITIALIZE]: (state) => initialState,
    [CHANGE_FIELD]: (state, { payload: { key, value } }) => ({
      ...state,
      [key]: value,
    }),
    // 🔻 성공시 post = { title, body, tags } (= response.data)
    [WRITE_POST_SUCCESS]: (state, { payload: post }) => ({
      ...state,
      post,
    }),
    // 🔻 실패시 
    [WRITE_POST_FAILURE]: (state, { payload: postError }) => ({
      ...state,
      postError,
    }),
  },
  initialState,
);

export default write;

rootSaga 등록

import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import post, { postSaga } from './post';
import write, { writeSaga } from './write';

const rootReducer = combineReducers({
  auth,
  loading,
  user,
  post,
  write,
});

export function* rootSaga() {
  yield all([authSaga(), userSaga(), postSaga(), writeSaga()]);
}

export default rootReducer;

WriteActionButtonsContainer 컴포넌트 생성

containers/write/WriteActionButtonsContainer.js 생성

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

const WriteActionButtonsContainer = () => {
  const navigate = useNavigate();
  const dispatch = useDispatch();

  const { title, body, tags, post, postError } = useSelector(({ write }) => ({
    title: write.title,
    body: write.body,
    tags: write.tags,
    post: write.post,
    postError: write.postError,
  }));

  // 🔻 writePost 액션 디스패치
  const onPublish = () => {
    dispatch(writePost({ title, body, tags }));
  };

  // 
  const onCancel = () => {
    navigate(-1); /// 이전 페이지로 이동 (history.go(-1)과 같음)
  };

  // 🔻 성공, 실패시 작업
  useEffect(() => {
    if (post) {
      const { _id, user } = post;
      // post 작성 성공시 - 해당 포스트 조회 경로로 이동
      navigate(`/@${user.username}/${_id}`); // history.push()와 동일
    }
    if (postError) {
      console.log(postError);
    }
  }, [navigate, post, postError]);

  
  // 🔻 onPublish, onCancel을 props로 전달
  return (
    <WriteActionButtons
      onPublish={onPublish}
      onCancel={onCancel}
    />
  );
};

export default WriteActionButtonsContainer;
  • WritePage 렌더링 수정
import Responsive from '../components/common/Responsive';
import EditorContainer from '../containers/write/EditorContainer';
import TagBoxContainer from '../containers/write/TagBoxContainer';
import WriteActionButtonsContainer from '../containers/write/WriteActionButtonsContainer';

const WritePage = () => {
  return (
    <Responsive>
      <EditorContainer />
      <TagBoxContainer />
      <WriteActionButtonsContainer />
    </Responsive>
  );
};

export default WritePage;

이제 글을 작성한 후 '포스트 작성' 버튼을 누르면 완성.

  • 이런식으로 뜨면 성공.
  • mongoDB compass에도 잘 등록됨.
profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글