목표
1. 검색 범위 설정하기
2. 관리자 계정으로 로그인 한 경우 데이터 삭제 가능하도록 변경
3. 로그인하지 않은 경우 자동으로 로그인 페이지로 이동하기
4. api 요청에 실패한 경우 로직 추가
🔎 static/constant
export const searchType = [
{type: "ALL", print: "제목+내용"},
{type: "CONTENTS", print: "내용"},
{type: "TITLE", print: "제목"},
{type: "WRITER", print: "작성자"}
]
🔎 Board
검색어를 입력하는 TextField 컴포넌트의 value를 상태값으로 사용하지 않았듯이, select 컴포넌트의 선택된 값도 상태값 대신 일반 상수를 갱신하는 식으로 사용하려고 했다. (상태값을 사용하면 Board 전체가 재렌더링되면서 검색어 입력창의 데이터도 날라갈 것이다.)
따라서 아래처럼 상수와 setter를 추가해주었다.
let searchType = searchTypes[0].type;
const setSearchType = (value) => {
searchType = value;
console.log(searchType);
}
그리고 Select 컴포넌트를 추가해주었는데, 문제가 발생했다. 😑
보기에는 아래처럼 Selector가 잘 추가된 것 같지만 이를 클릭하고 다른 값을 선택하여도 Selector에 보여지는 값은 여전히 "제목+내용"인 것이다.
다른 MenuItem을 선택하면 변경되는 searchType | 변경되지 않는 view |
---|---|
문제는 Select 컴포넌트의 value에 있었는데, 컴포넌트가 갱신되지 않기 때문에 value 값이 변동될 여지가 없는 것이다. 위 캡처에는 searchType[0].type
이라고 되어있지만 (컴포넌트 내에서 선언한) 변수인 searchType
를 할당해도 어짜피 재렌더링 되지 않기 때문에 변하지 않는다.
결국 Select를 재렌더링 시켜야 문제가 해결될 것이므로 이 컴포넌트를 분리하도록 하겠다.
이 컴포넌트는 재활용할 가능성이 높다고 생각했기 때문에 Board 컴포넌트 내부의 하위 컴포넌트로 분리하지 않고 개별적인 컴포넌트로 분리할 것이다.
따라서 searchTypes로 대입됐던 상수값(constant.js)이 가지고 있는 특정성을 제거한다.
export const searchType = [
{value: "ALL", print: "제목+내용"},
{value: "CONTENTS", print: "내용"},
{value: "TITLE", print: "제목"},
{value: "WRITER", print: "작성자"}
]
이전에 type
이었던 키 이름을 value
로 변경하였다. 실제 값은 value로 호출하고, 사용자에게 보여줄 UI는 print를 사용할 수 있다.
🔎 Selector
components
폴더 하위에 Selector
파일을 생성한다.
import {MenuItem, Select} from "@material-ui/core";
import React, {useState} from "react";
import {makeStyles} from "@material-ui/core/styles";
const useStyles = makeStyles(theme => ({
selectEmpty: {
marginTop: theme.spacing(2),
},
}));
export default function Selector({dataList}) {
const classes = useStyles();
const [selectedValue, setSelectedValue] = useState(dataList[0].value);
return (<Select
value={selectedValue}
onChange={(event) => setSelectedValue(event.target.value)}
displayEmpty
className={classes.selectEmpty}
inputProps={{'aria-label': 'Without label'}}
>
{dataList.map((data) => (
<MenuItem key={data.value} value={data.value} selected>
{data.print}
</MenuItem>
))}
</Select>);
}
개별적으로 분리한 컴포넌트이므로 상태값에 변화가 있어도 Selector 컴포넌트만 재렌더링되기 때문에 useState로 selectedValue
를 선언했다.
🔎 Board
검색어 입력창과 같은 댑스에 Selector를 추가해주자.
dataList로 넘겨준 searchTypes는 constant.js의 searchType의 alias이다.
import {BOARD_PAGE_SIZE, searchType as searchTypes} from '../static/constant';
잘 되는지 실행해보자!
Selector의 아이템 목록 | 선택한 모습 |
---|---|
그럼 Selector에서 관리하고 있는 상태값은 어떻게 상위 컴포넌트로 전달해줄까?
최종적으로 액션 생성 함수를 통해 데이터를 갱신하는 함수는 BoardContainer에 선언되어있다. BoardContainer>Board>Selector 의 계층구조를 가지고 있기 때문에 selectedValue
는 두 단계 위의 컴포넌트까지 전달되어야 할 것이다.🤔
그리고 이런 번거로운 작업을 해소시켜주는게 Redux였다. 따라서 Selector 내부의 상태값인 selectedValue를 스토어로 옮겨줄 것이다.
🔎 type.js
Selector는 범용 컴포넌트이지만 액션은 특정성을 가지게 선언한다.
SEARCH_TYPE_SELECTOR_CHANGE: 'BOARD/SEARCH_TYPE_SELECTOR_CHANGE'
🔎 reduce.js
const initialState = {
pageNumber: 0,
pageSize: BOARD_PAGE_SIZE,
selectedData: [],
isModalOpen: false,
modalData: {},
isWriteModal: false,
isSearch: false,
keyword: "",
boardId: 0,
searchType: "", // 추가한 상태
};
export default handleActions({
// ( ... 생략 )
[type.SEARCH_TYPE_SELECTOR_CHANGE]: (state, action) => ({
...state,
searchType: action.payload
})
}, initialState
)
🔎 action.js
searchType을 갱신할 액션 생성 객체를 정의한다.
export const searchTypeSelectorChange = (value) => ({
type : type.SEARCH_TYPE_SELECTOR_CHANGE,
payload : value
})
새로운 상태값과 액션 생성 함수를 추가한다.
Board 컴포넌트에는 각각 searchType과 handleChangeSearchTypeSelect라는 이름으로 전달하였다.
Selector에는 특정성이 없는 이름으로 전달해준다. 각각 selectedValue와 handleChangeSelect라는 인수로 전달하였다.
Selector 컴포넌트 내부에서 정의했던 상태를 제거하고 전달받은 인수로 value와 onChange의 함수를 대체한다.
리듀서에서 searchType 상태의 초기화 값을 ""
로 선언했기 때문에 Select 컴포넌트의 value의 초기값 설정을 위해 selectedValue(Board에서 searchType 전달)이 ""
인 경우와 아닌 경우로 나누었다.
초기 상태 | 초기 UI |
---|---|
전체 코드는 아래와 같다.
import {MenuItem, Select} from "@material-ui/core";
import React, {useState} from "react";
import {makeStyles} from "@material-ui/core/styles";
const useStyles = makeStyles(theme => ({
selectEmpty: {
marginTop: theme.spacing(2),
},
}));
export default function Selector({dataList, selectedValue, handleChangeSelect}) {
const classes = useStyles();
return (<Select
value={selectedValue === "" ? dataList[0].value : selectedValue}
onChange={event => handleChangeSelect(event.target.value)}
displayEmpty
className={classes.selectEmpty}
inputProps={{'aria-label': 'Without label'}}
>
{dataList.map((data) => (
<MenuItem key={data.value} value={data.value} selected>
{data.print}
</MenuItem>
))}
</Select>);
}
셀렉터 옵션을 변경하면 다음처럼 액션이 발동하며 상태값이 변경되는 것을 확인 할 수 있다.
작성자에 1212
가 포함된 게시글 검색
내용에 ㅋㅋ
가 포함된 게시글 검색
사실 서버측에서는 role이 존재했지만 프론트에서 사용하고 있지 않았다. 따라서 서버에서는 관리자 권한을 가지고 있으면 게시글을 수정/삭제할 수 있도록 코드가 짜여있는데 (수정하는건 아무래도 좀 이상해서 😑..) 클라이언트에서도 이를 활용해 관리자인 경우엔 삭제 버튼이 나타나고, 요청을 보낼 수 있도록 변경할 것이다.
서버 측에서 로그인 정보를 내려주고 있지 않기 때문에, 살짝 변경이 필요했다.
로그인에 성공하면 UserDto를 클라이언트로 반환해주도록 하였다.
🔎 userService
변경 전 | 변경 후 |
---|---|
변경 후 코드
public UserDto login(UserForm user) {
UserEntity userEntity = userRepository.findByAccountId(user.getAccountId());
if (userEntity == null){
throw new FreeBoardException(UserExceptionType.NOT_FOUND_USER);
}
if (userEntity.getPassword().equals(user.getPassword()) == false){
throw new FreeBoardException(UserExceptionType.WRONG_PASSWORD);
}
return UserDto.of(userEntity); // react-front에서 사용하기 위해 수정
}
🔎 userApiController
변경 전 | 변경 후 |
---|---|
변경 후 코드
@PostMapping(params = {"type=LOGIN"})
private ResponseEntity<UserDto> login(@RequestBody UserForm user){
UserDto userDto = userService.login(user);
httpSession.setAttribute("USER", user);
return ResponseEntity.ok(userDto);
}
🔎 reducer.js
권한을 저장할 role
state를 추가하고, 로그인에 성공할 시 이를 갱신할 수 있도록 한다.
import {handleActions} from 'redux-actions'
import type from './type';
const initialState = {
accountId : null,
isLogged : false,
errorMessage : null,
isLoginPage : true,
role : null // 추가된 부분
}
export default handleActions({
[type.LOGIN_SUCCESS] : (state, action) => ({
...state,
accountId: action.payload.accountId, // 수정된 부분
role: action.payload.role, // 추가된 부분
isLogged: true
}),
[type.LOGIN_FAIL] : (state, action) => ({
...state,
isLogged: false,
errorMessage: action.payload
}),
[type.PAGE_CHANGE] : (state, action) => ({
...state,
errorMessage: null,
isLoginPage: !state.isLoginPage
}),
[type.JOIN_FAIL] : (state, action) => ({
...state,
errorMessage: action.payload
})
}, initialState
);
🔎 action.js
액션 생성 함수도 payload key를 2개 생성하도록 변경한다.
export const loginSuccess = createAction(
type.LOGIN_SUCCESS, (accountId, role) => ({accountId, role})
);
mapDispatchToProps
에 정의된 loginSuccess의 인자를 (accountId)
에서 (accountId, role)
로 변경한 뒤, loginSubmit 함수를 아래와 같이 고쳐준다.
로그인에 성공하면 accountId 뿐만아니라 role도 갱신되는 것을 확인 할 수 있다.
상태값을 추가한 뒤, ContentsModal
에 이를 넘겨준다.
mapStateToProps | ContentsModal |
---|---|
helper 폴더 하위에 specification.js
파일을 추가한다. 앞으로 특정 조건을 판단하는 함수는 여기에 작성할 것이다.
export const isAdminRole = (role) => {
return role === "ADMIN";
}
물론 필요한 파일 안에서 함수를 생성해도 되지만, 범용으로 사용하고 수정될 여지가 큰 것들은 한 번에 관리하기 위해서 만들었다.
ContentsModal 컴포넌트 내에 정의된 내장 컴포넌트인 ModalButtons를 수정한다.
- | ModalButtons |
---|---|
수정 전 | |
수정 후 |
function ModalButtons({writer, userLoggedIn, setIsModify, handleDelete, userRoleLoggedIn}) {
return (
<span>
<Grid>
{writer.accountId == userLoggedIn ? <Button color="primary" onClick={() => setIsModify(true)}>Modify</Button> : null}
{isAdminRole(userRoleLoggedIn) || writer.accountId == userLoggedIn ? <Button color="secondary" onClick={() => handleDelete()}>Delete</Button> : null}
</Grid>
</span>
);
}
관리자 권한을 가진 아이디로 로그인한 뒤 내가 쓴 글이 아니라도 삭제 버튼이 노출되는지 확인해보자 🙋🏻
내가 쓴 글이 아니기 때문에 수정버튼은 나타나지 않고, 관리자 권한으로 인해 삭제 버튼만 나타났다. 버튼을 클릭하면 삭제가 수행되는지 확인한다.
BoardContainer에 선언되어있던 useEffect
를 사용하여 스토어에 계정이 저장되어있는지 확인한다. 이미 accountId는 인수로 받고 있으므로 이 값이 (초기화 상태인) null
인 경우에는 라우터를 이용해 메인페이지(path: "/"
)로 강제 이동시킨다.
이전에 메인 페이지에서 사용한 api 요청 코드를 살펴보자.
🔎userApi
export async function loginApi(accountId, password) {
try {
return await axios.post(DOMAIN+'/api/users?type=LOGIN',
{accountId: accountId, password: password});
} catch (error) {
const response = { data : {
code : error.response.status,
message: error.message
}};
return response;
}
}
요청에 실패할 경우 error 객체에서 필요한 정보만 뽑아 응답값으로 반환해 주고, 이 데이터는 다음과 같이 다른 액션 생성 함수에 전달된다.
🔎 MainContainer
if (typeof response.data.code != "undefined") {
loginFail(response.data.message);
}
이 형태를 board의 액션 생성 함수(미들웨어)에 그대로 적용할 것이다.
user store에서도 에러를 처리하기위한 상태값을 관리하고 있다. board에서도 스토어를 이용해 이를 관리할 것이다.
다음 액션을 추가한다.
API_REQUEST_ERROR: 'BOARD/API_REQUEST_ERROR'
const initialState = {
// (... 생략)
isRequestFail: false,
errorCode: "",
errorMessage: "",
};
요청에 실패하면 isRequestFail은 true가 되고 에러코드와 메세지를 저장할 것이다. 액션 핸들러에 다음 타입을 추가한다.
[type.API_REQUEST_ERROR]: (state, action) => ({
...state,
isRequestFail: true,
errorCode: action.payload.code,
errorMessage: action.payload.message,
})
요청에 성공한 경우에는 isRequestFail을 false로 바꿔줘야한다. 미들웨어를 사용해 요청에 실패하면 데이터를 아예 갱신하지 않을 것이므로, 요청을 성공한다는 것은 데이터를 갱신하는 액션 생성 함수를 호출한 경우가 될 것이다.
따라서 다음과 같은 액션이 발동되는 경우에 isRequestFail: false
를 추가해준다.
새로운 액션 생성 함수를 추가해주자.
export const apiRequestError = (errorCode, errorMessage) => ({
type: type.API_REQUEST_ERROR,
payload: {
code: errorCode,
message: errorMessage
}
})
그리고 이 액션 생성 함수는 컴포넌트에서 호출하는 것이 아니라, 미들웨어에서 호출하도록 할 것이다.
수정 전 | 수정 후 |
---|---|
수정 전에는 응답값이 정상이면 바로 selectedData를 갱신하였지만, 수정 후에는 1) 응답값이 정상이라도 errorCode(서버에서 제공)를 가지고 있거나 2) catch
구문이 error를 잡아내면 apiRequestError
액션 생성 함수를 디스패치하도록 하였다.
총 4개의 액션 생성 함수가 변경되었다.
전체 코드는 아래와 같다.
import type from './type'
import {get, update, getForSearch} from "../../api/boardApi";
import {getData} from "../../../helper/boardHelper";
export const changePage = (pageNumber, pageSize, searchType, keyword, isSearch) => dispatch => {
const requestApi = (isSearch && keyword.trim() != "" ?
getForSearch(pageNumber + 1, pageSize, searchType, keyword) : get(pageNumber + 1, pageSize));
return requestApi.then(response => {
if (typeof response.data.code != "undefined") {
dispatch(apiRequestError("", response.data.message))
} else {
const selectedData = getData(pageNumber, pageSize, response);
dispatch({
type: type.CHANGE_PAGE,
payload: {
pageNumber: pageNumber,
pageSize: pageSize,
selectedData: selectedData
}
})
}
}).catch(error => {
dispatch(apiRequestError(error.response.status, error.message))
})
}
export const keywordSearch = (pageSize, searchType, keyword) => dispatch => {
const pageNumber = 0;
return getForSearch(pageNumber + 1, pageSize, searchType, keyword)
.then(response => {
if (typeof response.data.code != "undefined") {
dispatch(apiRequestError("", response.data.message))
} else {
const selectedData = getData(pageNumber, pageSize, response);
dispatch({
type: type.KEYWORD_SEARCH,
payload: {
keyword: keyword,
pageNumber: pageNumber,
pageSize: pageSize,
selectedData: selectedData
}
})
}
}).catch(error => {
dispatch(apiRequestError(error.response.status, error.message))
})
}
export const changeShowAllContents = (pageSize) => dispatch => {
const pageNumber = 0;
return get(pageNumber + 1, pageSize)
.then(response => {
if (typeof response.data.code != "undefined") {
dispatch(apiRequestError("", response.data.message))
} else {
const selectedData = getData(pageNumber, pageSize, response);
dispatch({
type: type.CHANGE_SHOWING_ALL_CONTENTS,
payload: {
pageNumber: pageNumber,
pageSize: pageSize,
selectedData: selectedData
}
})
}
}).catch(error => {
dispatch(apiRequestError(error.response.status, error.message))
})
}
export const clickRow = (rowData) => ({
type: type.CLICK_ROW,
payload: rowData
})
export const closeModal = () => ({
type: type.CLOSE_MODAL,
})
export const clickWriteButton = () => ({
type: type.CLICK_WRITE_BUTTON,
})
export const modifyData = (id, updatedData, allData) => dispatch => {
return update(id, updatedData)
.then(response => {
if (typeof response.data.code != "undefined") {
dispatch(apiRequestError("", response.data.message))
} else {
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 => {
dispatch(apiRequestError(error.response.status, error.message))
})
}
export const searchTypeSelectorChange = (value) => ({
type: type.SEARCH_TYPE_SELECTOR_CHANGE,
payload: value
})
export const apiRequestError = (errorCode, errorMessage) => ({
type: type.API_REQUEST_ERROR,
payload: {
code: errorCode,
message: errorMessage
}
})
mapStateToProps | Board |
---|---|
새로 스토어에 추가한 3개의 상태값을 컨테이너에 선언해 연결되도록하고, Board 컴포넌트에 그대로 전달해준다.
액션 생성 함수인 apiRequestError
는 컴포넌트에서 호출하지 않으므로 추가하지 않아도 된다.
isRequestFail, errorCode, errorMessage 중 하나의 상태라도 변경되는 경우에 useEffect가 발동하며, isRequestFail이 false일 때 경고창을 띄우게 하였다.
useEffect(()=>{
if(isRequestFail === true){
alert(errorCode+" : "+errorMessage);
}
}, [isRequestFail, errorCode, errorMessage]);
로그인 후 강제로 서버를 종료하여 오류 상황을 만들어보았다.
여기까지 하면 이전에 있던 코드(서버에서 만들어둔 깡통 js)에 대한 리팩토링은 끝이다. 🤔
서버에 다른 기능을 추가하는대로 이 코드도 계속 갱신하도록 하겠다.
모든 코드는 github에서 확인 할 수 있습니다.