📝 DAY 03 - 220520
- 글쓰기 기능 구현
- quill 라이브러리 (에디터)
- 에디터 UI 구현
- 리덕스 상태관리
- API 연동
$ yarn add quill
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;
pages/WritePage 수정
import Editor from '../components/write/Editor';
import Responsive from '../components/common/Responsive';
const WritePage = () => {
return (
<Responsive>
<Editor />
</Responsive>
);
};
export default WritePage;
...
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;
modules
의 toolbar - 문서 참조🔺 modules.toolbar에 등록한 것이 툴바에 구현된다.
(header / bold,italic... / list / blockquote 등)
-> placeholder 잘 적용됨.
-> 에디터의 기능을 구현한 예.
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임.
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;
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;
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 완성!
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;
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개의 컨테이너를 생성함.
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;
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;
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
를 쓸때는 ? -> 첫 렌더링시 or willUnmount시에만 실행되어야 할때 사용.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;
...
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;
lib/api/posts.js 생성
import client from './client';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
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;
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;
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;
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;
이제 글을 작성한 후 '포스트 작성' 버튼을 누르면 완성.