목표
1. 로그인 후 게시판으로 넘어 올 때, 상태유지하도록 변경하기
2. 모달을 띄워 상세글을 확인 할 수 있도록 변경한다.
3. 게시글 수정 요청을 전송하고 출력 데이터에도 (리다이렉트없이) 반영되도록 한다.
BoardContainer
의 mapStateToProps
에 accountId: state.main.accountId
를 추가하고, Board 컴포넌트에 값을 전달해보자.
Board 컴포넌트에 값을 전달해서 출력하면 아마 다음처럼 null 값이 뜰 것이다.
문제는 MainContainer
에 있다. 바로 로그인 요청이 성공할 시 location.href
로 페이지를 이동하는 코드 때문이다. 이 경우 라우터의 자동 url 감지로 라우팅에는 성공하지만 (BoardContainer가 랜더링됨) 상태가 보존되지 않고 초기화된다. 따라서 loginSuccess
액션으로 갱신된 accountId 값도 다시 null이 되는 것.
이를 해결하기 위한 훅이 react-router-dom
이 지원하는 useHistory이다.
import { useHistory } from "react-router-dom";
// ( ... 생략 )
const MainContainer = ({accountId, isLogged, isLoginPage, errorMessage, loginSuccess, loginFail, pageChange, joinFail}) => {
let history = useHistory();
const loginSubmit = async (id, password) => {
var response = await loginApi(id, password);
if (typeof response.data.code != "undefined"){
loginFail(response.data.message);
} else {
loginSuccess(id);
alert("로그인에 성공하셨습니다. 게시판으로 이동합니다.");
history.push("/board");
}
}
// ( ... 후략 )
}
사용하는 방법은 보다시피 매우 간단하다. useHistory()를 이용해 객체를 생성한 뒤 push(${link})
를 통해 라우팅이 가능하다. 라우터를 사용하면 스토어의 상태를 보존하기 때문에 다음처럼 계정 아이디가 올바르게 나오는 것을 확인할 수 있다.
게시글을 읽거나 수정, 삭제하는 작업은 모두 모달에서 이뤄지게 할 것이다.
우선, "board" 스토어에 필요한 상태와 액션을 추가할 것이다.
모달을 열고 닫는 작업은 모두 액션을 수행함으로써 일어날 것이다. 정확히 말하면 모달을 열거나 닫으려는 시도를 하면 상태값인 isModalOpen
이 true <> false
로 토글될 것이고, 이 boolean값을 이용해 모달을 보여주거나 숨길 것이다.
🔎 type.js
export default {
CHANGE_PAGE: 'BOARD/CHANGE_PAGE',
CLICK_ROW: 'BOARD/CLICK_ROW', // 게시글을 클릭한다.
CLOSE_MODAL: 'BOARD/CLOSE_MODAL' // 모달을 닫는다.
}
🔎 action.js
//생성 함수 추가
export const clickRow = (rowData) => ({
type: type.CLICK_ROW,
payload: rowData
})
export const closeModal = () => ({
type: type.CLOSE_MODAL,
})
🔎 reducer.js
const initialState = {
pageNumber: 0,
pageSize: BOARD_PAGE_SIZE,
selectedData: [],
isModalOpen: false, // 모달을 열거나 닫을 때 사용한다.
modalData: {} // 모달을 열었을 때 보여줄 데이터
}
export default handleActions({
[type.CHANGE_PAGE]: (state, action) => ({
...state,
pageNumber: action.payload.pageNumber,
pageSize: action.payload.pageSize,
selectedData: action.payload.selectedData
}),
[type.CLICK_ROW]: (state, action) => ({ // 모달을 연다
...state,
isModalOpen: true,
modalData: action.payload,
}),
[type.CLOSE_MODAL]: (state, action) => ({ // 모달을 닫는다.
...state,
isModalOpen: false,
modalData: {},
})
}, initialState
)
새로 추가된 스토어 내용에 맞춰서 BoardContainer도 변경해준다.
const mapStateToProps = state => ({
pageNumber : state.board.pageNumber,
pageSize : state.board.pageSize,
selectedData : state.board.selectedData,
isModalOpen: state.board.isModalOpen,
modalData: state.board.modalData,
accountId: state.main.accountId
})
const mapDispatchToProps = dispatch => ({
changePage : (pageNumber, pageSize) => dispatch(changePage(pageNumber, pageSize)),
clickRow: (rowData) => dispatch(clickRow(rowData)),
closeModal: () => dispatch(closeModal())
})
컴포넌트를 리턴할 때 isModalOpen이 true일 경우에만 모달을 랜더링하도록 아래처럼 작성해준다. (아직 ContentsModal을 만들지 않아서 에러가 뜰 것이다. 바로 다음에 컴포넌트를 생성하니 걱정하지 말자 🙅🏻)
return (
<div>
<Board
pageNumber={pageNumber}
pageSize={pageSize}
selectedData={selectedData}
handleChangePageNumber={handleChangePageNumber}
handleChangePageSize={handleChangePageSize}
handleRowClick={clickRow}
columns={columns}
data={selectedData}
accountId={accountId}
/>
{ isModalOpen ?
<ContentsModal
isModalOpen={isModalOpen}
modalData={modalData}
handleClose={closeModal}
/>
: null}
</div>
);
clickRow
액션 생성 함수는 Board 컴포넌트에 handleRowClick={clickRow}
로 전달되고 있다. 이름에서 유추할 수 있듯이 각 행을 클릭했을 때 액션이 수행되도록 할 것이다.
Board 컴포넌트 내 MaterialTable
의 onRowClick props에 다음과 같이 전달해주자.
// ( ... 생략 )
export default function Board({pageNumber, pageSize, selectedData, columns, data, accountId, handleChangePageNumber, handleChangePageSize, handleRowClick}) {
return (
<MaterialTable
onChangePage={handleChangePageNumber}
onChangeRowsPerPage={handleChangePageSize}
icons={tableIcons}
title={"게시판 (login user : "+accountId+")"}
columns={columns}
data={selectedData}
options={{
paginationType: "stepped",
pageSize: BOARD_PAGE_SIZE
}}
onRowClick={(event, rowData) => { // 추가된 부분!!!
handleRowClick(rowData);
}}
/>
);
}
components
하위에 ContentsModal.js
파일을 생성한다. 모달도 Material-Ui를 사용할 것이다.
export default function ContentsModal({isModalOpen, modalData, handleClose}) {
const classes = useStyles();
// getModalStyle is not a pure function, we roll the style only on the first render
const [modalStyle] = React.useState(getModalStyle);
const body = (
<div style={modalStyle} className={classes.paper}>
<h2 id="simple-modal-title">Text in a modal</h2>
<p id="simple-modal-description">
Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
</p>
</div>
);
return (
<div>
<Modal
open={isModalOpen}
onClose={handleClose}
aria-labelledby="simple-modal-title"
aria-describedby="simple-modal-description"
>
{body}
</Modal>
</div>
);
}
모달 컴포넌트의 props 중 open
과 onClose
에 집중하자.
모달이 열리거나 닫히는 것은 전적으로 open의 "값"에 달려있다. boolean 타입을 인수로 받으며 나는 상태값인 isModalOpen
을 사용하였다.
이 흐름을 잘 파악하고 있어야한다.
특히, isModalOpen이 false가 돼야 모달이 닫히는게 아니다.
Modal
컴포넌트의 open
props가 false가 된다. 게시글을 클릭했을 때 아래 이미지처럼 모달이 뜨면 성공이다. esc 키를 누르거나 모달 밖을 클릭했을 때 모달이 닫기는지도 확인한다.
다음은 진짜 데이터를 출력해 볼 것이다.
material-ui의 컴포넌트를 몇가지 추가해주자.
import {Button, Modal, TextareaAutosize, TextField} from '@material-ui/core';
body는 다음처럼 구성할 것이다.
여기서 사용하는 modalData는 rowClick
액션으로 갱신된 상태값이다.
const body = (
<div style={modalStyle} className={classes.paper}>
<h2 id="simple-modal-title">{modalData.title}</h2>
<p id="simple-modal-description">
{modalData.contents}
<br/><br/>
<p>작성자 : {modalData.writer.accountId}</p>
<p>작성일 : {modalData.createdAt}</p>
<Button color="primary">Modify</Button>
<Button onClick={() => {handleClose();}}>Close</Button>
</p>
</div>
);
데이터가 잘 출력되는 것을 확인 할 수 있다. CLOSE
버튼을 누르면 모달이 닫히는지 확인하자.
위에서 모달이 닫히는 것과 상태값의 상관관계에 대해서 설명했었는데, <Button onClick={() => {handleClose();}}>Close</Button>
에서 클릭시에 handleClose라는 액션을 호출하는 것도 그 때문이다.
한 모달에서 사용자에게 보여줄 컴포넌트만 변경할 수 있도록 isModify
라는 ContentsModal의 자체 상태값을 정의할 것이다.
이 값은 다음처럼 사용된다.
예상 할 수 있듯이 MODIFY 버튼을 클릭하면 setIsModify(true)
를 호출하고, 다른 버튼을 클릭하면 setIsModify(false)
를 호출할 것이다. (새로 선택된 게시글은 read 부터 보여줘야하기 때문에)
전체 코드는 아래와 같다.
export default function ContentsModal({isModalOpen, modalData, handleClose}) {
const classes = useStyles();
// getModalStyle is not a pure function, we roll the style only on the first render
const [modalStyle] = React.useState(getModalStyle);
const [isModify, setIsModify] = useState(false);
const read = (
<div style={modalStyle} className={classes.paper}>
<h2 id="simple-modal-title">{modalData.title}</h2>
<p id="simple-modal-description">
{modalData.contents}
<br/><br/>
<p>작성자 : {modalData.writer.accountId}</p>
<p>작성일 : {modalData.createdAt}</p>
<Button color="primary" onClick={() => setIsModify(true)}>Modify</Button>
<Button onClick={() => {
setIsModify(false);
handleClose();
}}>Close</Button>
</p>
</div>
);
const modify = (
<div style={modalStyle} className={classes.paper}>
<form className={classes.form} noValidate onSubmit={(event) => {
event.preventDefault();
}}>
<TextField
id="outlined-full-width"
label="제목"
fullWidth
InputLabelProps={{
shrink: true,
}}
variant="outlined"
defaultValue={modalData.title}
margin="normal"
required
/>
<TextField
id="outlined-multiline-static"
label="내용"
multiline
rows={4}
defaultValue={modalData.contents}
variant="outlined"
fullWidth
margin="normal"
required
/>
<br/><br/>
<p>작성자 : {modalData.writer.accountId}</p>
<p>작성일 : {modalData.createdAt}</p>
<Button type="submit" color="primary" className={classes.submit}>Save</Button>
<Button onClick={() => setIsModify(false)}>Back</Button>
<Button onClick={() => {
setIsModify(false);
handleClose();
}}>Close</Button>
</form>
</div>
)
return (
<div>
<Modal
open={isModalOpen}
onClose={handleClose}
aria-labelledby="simple-modal-title"
aria-describedby="simple-modal-description"
>
{isModify ? modify : read}
</Modal>
</div>
);
}
게시글 클릭 시 | MODIFY 버튼 클릭 시 |
---|---|
게시글 수정의 로직은 다음처럼 흘러갈 것이다.
Main 컴포넌트에서도 사용했지만 material-ui에서 제공하는 컴포넌트는 댑스가 깊어 value를 직접 가져오는 것이 불가능하다.
따라서 onChange prop에 setState 함수를 전달해 데이터가 변경될 때마다 상태값을 갱신해주도록 할 것이다.
현재는 사용하는 데이터가 title과 contents 뿐이므로 (작성자명과 작성날짜는 변경불가하며, 수정날짜는 서버에서 자동으로 갱신한다.) 두 값에 대한 상태를 정의해준다.
초기화 값은 현재 띄워진 모달의 값. 즉, 변경되기 전의 원래데이터이다.
modify 객체의 TextField에 onChange를 이용하여 상태들을 각각 갱신시켜주자.
그리고 폼이 전송됐을 때 실행할 로직을 익명함수를 이용하여 작성해 주었다.
전체 코드는 아래와 같다.
import React, {useState} from 'react';
import {makeStyles} from '@material-ui/core/styles';
import {Button, Modal, TextField, Typography} from '@material-ui/core';
function rand() {
return Math.round(Math.random() * 20) - 10;
}
function getModalStyle() {
const top = 50 + rand();
const left = 50 + rand();
return {
top: `${top}%`,
left: `${left}%`,
transform: `translate(-${top}%, -${left}%)`,
};
}
const useStyles = makeStyles((theme) => ({
paper: {
position: 'absolute',
width: 400,
backgroundColor: theme.palette.background.paper,
border: '2px solid #000',
boxShadow: theme.shadows[5],
padding: theme.spacing(2, 4, 3),
},
}));
export default function ContentsModal({isModalOpen, modalData, handleClose, handleModify}) {
const classes = useStyles();
// getModalStyle is not a pure function, we roll the style only on the first render
const [modalStyle] = React.useState(getModalStyle);
const [isModify, setIsModify] = useState(false);
const [title, setTitle] = useState(modalData.title);
const [contents, setContents] = useState(modalData.contents);
const combineData = () => {
return {
title: title,
contents: contents
}
}
const read = (
<div style={modalStyle} className={classes.paper}>
<h2 id="simple-modal-title">{modalData.title}</h2>
<Typography variant="body1" gutterBottom>{modalData.contents}</Typography>
<hr/>
<Typography variant="body2" gutterBottom>작성자 : {modalData.writer.accountId}</Typography>
<Typography variant="body2" gutterBottom>작성일 : {modalData.createdAt}</Typography>
<Button color="primary" onClick={() => setIsModify(true)}>Modify</Button>
<Button onClick={() => {
setIsModify(false);
handleClose();
}}>Close</Button>
</div>
);
const modify = (
<div style={modalStyle} className={classes.paper}>
<form className={classes.form} noValidate onSubmit={(event) => {
event.preventDefault();
var updatedData = combineData();
handleModify(updatedData);
}}>
<TextField
id="outlined-full-width"
label="제목"
fullWidth
InputLabelProps={{
shrink: true,
}}
variant="outlined"
defaultValue={modalData.title}
margin="normal"
onChange={event => setTitle(event.target.value)}
required
/>
<TextField
id="outlined-multiline-static"
label="내용"
multiline
rows={4}
defaultValue={modalData.contents}
variant="outlined"
fullWidth
margin="normal"
onChange={event => setContents(event.target.value)}
required
/>
<hr/>
<Typography variant="body2" gutterBottom>작성자 : {modalData.writer.accountId}</Typography>
<Typography variant="body2" gutterBottom>작성일 : {modalData.createdAt}</Typography>
<Button type="submit" color="primary" className={classes.submit}>Save</Button>
<Button onClick={() => setIsModify(false)}>Back</Button>
<Button onClick={() => {
setIsModify(false);
handleClose();
}}>Close</Button>
</form>
</div>
)
return (
<div>
<Modal
open={isModalOpen}
onClose={handleClose}
aria-labelledby="simple-modal-title"
aria-describedby="simple-modal-description"
>
{isModify ? modify : read}
</Modal>
</div>
);
}
read와 modify를 보면 <p> 태그 대신 Typography라는 컴포넌트를 사용하고 있는데, 계속 <p> 태그를 중첩해서 쓸 수 없다고 오류가 나길래 바꿔줬다 😑 실행하면 UI는 동일하다!
handleModify
를 정의해 주기전에 스토어에 필요한 것들을 추가할 것이다. 서버단의 데이터 업데이트가 성공 했을 때 액션 생성함수를 호출하고 상태값을 갱신시키는 작업이 필요하다.
아래 코드들은 추가된 내용만 있으니 이전 코드에 이어 붙이면 된다.
🔎 type.js
MODIFY_DATA: 'BOARD/MODIFY_DATA'
🔎 action
import {get, update} from "../../api/boardApi";
// ( .. 생략 )
export const modifyData = (id, updatedData, allData) => dispatch => {
return update(id, updatedData)
.then(response => {
allData.forEach(function (element) {
if(element.id == id){
for (var key in updatedData){
element[key] = updatedData[key];
}
return;
}
})
dispatch({
type: type.MODIFY_DATA,
payload: allData,
})
}).catch(error => {
/* error control */
})
}
🔎 BoardApi
export function update(id, updatedData) {
return axios.put(DOMAIN+'/api/boards/'+id, updatedData);
}
액션 생성 미들웨어(modifyData
)에서 받는 값들은 다음과 같다.
서버에 데이터 갱신 요청이 성공하면 변경된 값을 클라이언트에서 바로 반영할 수 있도록 현재 노출되는 데이터인 selectedData
를 수정할 것이다.
forEach로 전체를 순회하며 변경된 데이터를 발견하면 변경된 값(updatedData)로 갱신해준다. 아직까지는 스토어에 보관된 selectedData는 갱신되지 않았으며 최종적으로 dispatch된 액션 생성 객체에 이 값을 payload로써 전달한다.
🔎 reducer.js
[type.MODIFY_DATA]: (state, action) => ({
...state,
selectedData: action.payload,
})
리듀서에서는 payload(위에서 allData)를 selectedData에 전달하면서 값이 갱신된다. → 리다이렉트없는 재랜더링을 통해서 클라이언트에서도 변경된 데이터를 바로 확인할 수 있다.
ContentsModal에 전달할 handleModify를 정의해주자.
ContentsModal은 오브젝트 형태의 updatedData를 전달할 것이고, handleModify에서는 액션 생성 미들웨어에 적절한 값들을 인수로 전달해주기만 하면된다.
(여기서 modifyData와 closeModal은 액션 생성 함수이고, modalData와 selectedData는 스토어의 state이다.)
주의할 점은 selectedData를 전달하는게 아닌 복사본인 selectedData.slice(0)를 전달하는 것이다. selectedData를 전달하면 값이 아닌 레퍼런스가 전달되므로 modifyData의 then(..) 내부의 forEach()에서 데이터를 갱신할 때 상태가 실시간으로 바뀌어버린다. 즉 dispatch된 액션 생성 객체를 전달하기도 전에 selectedData가 갱신되는 것이다.
전체 코드는 다음과 같다
import React, {useEffect, useState} from "react";
import {connect} from 'react-redux';
import {changePage, clickRow, closeModal, modifyData} from "../store/modules/board/action";
import Board from "../components/Board";
import ContentsModal from "../components/ContentsModal";
import main from "../store/modules/main/reducer";
const BoardContainer = ({pageNumber, pageSize, selectedData, isModalOpen, modalData, accountId, changePage, clickRow, closeModal, modifyData}) => {
useEffect(() => {
changePage(pageNumber, pageSize);
}, [])
const columns = [
{title: '제목', field: 'title'},
{title: '작성자', field: 'writer.accountId'},
{title: '작성일시', field: 'createdAt'},
]
const handleChangePageNumber = (pageNumber) => {
changePage(pageNumber, pageSize);
};
const handleChangePageSize = (pageSize) => {
changePage(pageNumber, pageSize);
};
const handleModify = (updatedData) => {
modifyData(modalData.id, updatedData, selectedData.slice(0));
closeModal();
}
return (
<div>
<Board
pageNumber={pageNumber}
pageSize={pageSize}
selectedData={selectedData}
handleChangePageNumber={handleChangePageNumber}
handleChangePageSize={handleChangePageSize}
handleRowClick={clickRow}
columns={columns}
data={selectedData}
accountId={accountId}
/>
{ isModalOpen ?
<ContentsModal
isModalOpen={isModalOpen}
modalData={modalData}
handleClose={closeModal}
handleModify={handleModify}
/>
: null}
</div>
);
}
const mapStateToProps = state => ({
pageNumber : state.board.pageNumber,
pageSize : state.board.pageSize,
selectedData : state.board.selectedData,
isModalOpen: state.board.isModalOpen,
modalData: state.board.modalData,
accountId: state.main.accountId
})
const mapDispatchToProps = dispatch => ({
changePage : (pageNumber, pageSize) => dispatch(changePage(pageNumber, pageSize)),
clickRow: (rowData) => dispatch(clickRow(rowData)),
closeModal: () => dispatch(closeModal()),
modifyData: (id, updatedData, allData) => dispatch(modifyData(id, updatedData, allData)),
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(BoardContainer)
새로고침없이 데이터가 잘 갱신되는지 확인하자.
위와 같은 상태에서 SAVE를 클릭하면 모달이 닫히고 제목이 변경되어야 할 것이다.
서버쪽에서 자격이 없으면 수정이 안되게 막고 있긴 하나, 클라이언트에서도 실수가 없도록 막아줄 것이다.
어려운건 아니고, accountId를 비교해 같을 때만 수정버튼이 나오게 한 줄 추가하는 것이 끝이다. 🤔
<ContentsModal
userLoggedIn={accountId} // 추가 된 부분
isModalOpen={isModalOpen}
modalData={modalData}
handleClose={closeModal}
handleModify={handleModify}
/>
이미 Board 컴포넌트로 보내기 위해 accountId를 받고 있었으니 ContentsModal에도 userLoggedIn 이란 props로 보내준다.
const read = (
<div style={modalStyle} className={classes.paper}>
<h2 id="simple-modal-title">{modalData.title}</h2>
<Typography variant="body1" gutterBottom>{modalData.contents}</Typography>
<hr/>
<Typography variant="body2" gutterBottom>작성자 : {modalData.writer.accountId}</Typography>
<Typography variant="body2" gutterBottom>작성일 : {modalData.createdAt}</Typography>
// 추가된 부분
{typeof userLoggedIn != 'undefined' && userLoggedIn == modalData.writer.accountId ?
<Button color="primary" onClick={() => setIsModify(true)}>Modify</Button>
: null}
<Button onClick={() => {
setIsModify(false);
handleClose();
}}>Close</Button>
</div>
);
사용자가 로그인한 상태이고, 글 작성자와 동일할 때만 Modify 버튼이 나타나도록 변경한다.
실행해보자! 🙋🏻
내가 쓴 글 | 내가 쓰지 않은 글 |
---|---|
모든 코드는 github에서 확인할 수 있습니다.