목표
1. UI 변경하기
2. 검색 로직 완성하기
3. 버그 수정하기
검색창을 활성화하기 전에 UI를 살짝 고쳐보도록 하겠다.
사실 딱히 필요한 작업은 아니었지만.. MaterialTable에 포함된 서치는 이미 받아온 데이터에서 검색을 하기때문에 의도한 바와 다르므로 자체적으로 서치를 위한 UI를 달게 되었다.
바로 이전 글에서 사용했던 components
의 Toolbar
를 이용해 검색용 UI를 만들어보겠다.
우선 options에 search를 false
로 바꿔 기본적으로 제공되는 UI를 삭제해준다.
(Grid 지옥... 🤦🏻)
Material-Ui에서 사용하는 컴포넌트를 이용해 원하는 모양으로 정렬을 하려하니 저렇게 됐다. 👀..
전체 코드는 아래와 같다.
import React, {useEffect, useState} from 'react';
import MaterialTable, {MTableToolbar} from 'material-table';
import {Button, Grid, TextField, Typography} from "@material-ui/core";
import {forwardRef} from 'react';
import {useHistory} from "react-router-dom";
import {logoutApi} from "../store/api/userApi";
import {BOARD_PAGE_SIZE} from '../static/constant';
import AddBox from '@material-ui/icons/AddBox';
import ArrowDownward from '@material-ui/icons/ArrowDownward';
import Check from '@material-ui/icons/Check';
import ChevronLeft from '@material-ui/icons/ChevronLeft';
import ChevronRight from '@material-ui/icons/ChevronRight';
import Clear from '@material-ui/icons/Clear';
import DeleteOutline from '@material-ui/icons/DeleteOutline';
import Edit from '@material-ui/icons/Edit';
import FilterList from '@material-ui/icons/FilterList';
import FirstPage from '@material-ui/icons/FirstPage';
import LastPage from '@material-ui/icons/LastPage';
import Remove from '@material-ui/icons/Remove';
import SaveAlt from '@material-ui/icons/SaveAlt';
import Search from '@material-ui/icons/Search';
import ViewColumn from '@material-ui/icons/ViewColumn';
import AccountCircle from '@material-ui/icons/AccountCircle';
import {clickRow} from "../store/modules/board/action";
import {makeStyles} from '@material-ui/core/styles';
import SearchIcon from '@material-ui/icons/Search';
const tableIcons = {
Add: forwardRef((props, ref) => <AddBox {...props} ref={ref}/>),
Check: forwardRef((props, ref) => <Check {...props} ref={ref}/>),
Clear: forwardRef((props, ref) => <Clear {...props} ref={ref}/>),
Delete: forwardRef((props, ref) => <DeleteOutline {...props} ref={ref}/>),
DetailPanel: forwardRef((props, ref) => <ChevronRight {...props} ref={ref}/>),
Edit: forwardRef((props, ref) => <Edit {...props} ref={ref}/>),
Export: forwardRef((props, ref) => <SaveAlt {...props} ref={ref}/>),
Filter: forwardRef((props, ref) => <FilterList {...props} ref={ref}/>),
FirstPage: forwardRef((props, ref) => <FirstPage {...props} ref={ref}/>),
LastPage: forwardRef((props, ref) => <LastPage {...props} ref={ref}/>),
NextPage: forwardRef((props, ref) => <ChevronRight {...props} ref={ref}/>),
PreviousPage: forwardRef((props, ref) => <ChevronLeft {...props} ref={ref}/>),
ResetSearch: forwardRef((props, ref) => <Clear {...props} ref={ref}/>),
Search: forwardRef((props, ref) => <Search {...props} ref={ref}/>),
SortArrow: forwardRef((props, ref) => <ArrowDownward {...props} ref={ref}/>),
ThirdStateCheck: forwardRef((props, ref) => <Remove {...props} ref={ref}/>),
ViewColumn: forwardRef((props, ref) => <ViewColumn {...props} ref={ref}/>)
};
const useStyles = makeStyles((theme) => ({
margin: {
margin: theme.spacing(2),
},
root: {
flexGrow: 1,
},
paper: {
padding: theme.spacing(2),
textAlign: 'center',
color: theme.palette.text.secondary,
},
}));
export default function Board({pageNumber, pageSize, selectedData, columns, data, accountId, handleChangePageNumber, handleChangePageSize, handleRowClick, handleWriteButtonClick}) {
const classes = useStyles();
return (
<MaterialTable
onChangePage={handleChangePageNumber}
onChangeRowsPerPage={handleChangePageSize}
icons={tableIcons}
columns={columns}
data={selectedData}
options={{
search: false,
paginationType: "stepped",
pageSize: BOARD_PAGE_SIZE
}}
onRowClick={(event, rowData) => {
handleRowClick(rowData);
}}
components={{
Toolbar: props => (
<Grid container>
<Grid item xs={6}>
{typeof accountId != 'undefined' && accountId != null ?
<Grid className={classes.margin}>
<Button color="primary" size="medium" variant="outlined"
onClick={() => handleWriteButtonClick()}>글쓰기</Button>
</Grid>
: null
}
</Grid>
<Grid item xs={6}>
<Grid container alignItems="center" justify="flex-end" direction="row">
<Grid className={classes.margin}>
<Grid container spacing={1} alignItems="flex-end">
<Grid item>
<SearchIcon/>
</Grid>
<Grid item>
<TextField id="input-with-icon-grid"/>
</Grid>
<Grid>
<Button color="primary" size="medium">Search</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
)
}}
/>
);
}
그럼 게시판 타이틀과 로그인/로그아웃 버튼은 어디로 갔을까?🤔
components
하위에 Topbar
파일을 생성한다.
material-ui에서 제공하는 Toolbar 컴포넌트를 이용해서 상단바를 만들것이다. 이전 코드에서 필요한 부분을 가져왔기 때문에 새로운 컴포넌트로 재구성됐단 것 말고는 크게 달라진 것은 없다.
전체 코드는 아래와 같다.
import React from 'react';
import {makeStyles} from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu';
import {Grid} from "@material-ui/core";
import {logoutApi} from "../store/api/userApi";
import {useHistory} from "react-router-dom";
const useStyles = makeStyles((theme) => ({
root: {
flexGrow: 1,
},
menuButton: {
marginRight: theme.spacing(2),
},
title: {
flexGrow: 1,
},
}));
export default function Topbar({title, accountId}) {
const classes = useStyles();
const history = useHistory();
return (
<div className={classes.root}>
<AppBar position="static">
<Toolbar>
<IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="menu">
<MenuIcon/>
</IconButton>
<Typography variant="h6" className={classes.title}>
{title}
</Typography>
{typeof accountId != 'undefined' && accountId != null ?
<Button onClick={async () => {
await logoutApi();
history.push("/")
}} variant="contained" size="medium" color="primary" >로그아웃</Button>
:
<Button variant="contained" size="medium" color="primary" onClick={() => history.push("/")}>로그인</Button>
}
</Toolbar>
</AppBar>
</div>
);
}
Board 위에 Topbar를 추가해준다.
변경된 UI의 최종 모습은 다음과 같다.
Board 컴포넌트에서 Search 버튼 아래에 버튼을 하나 더 추가한다.
외부 라이브러리를 사용하다보니 예상치못한 문제가 생겨서 한참을 삽질하다왔다. 😑
전체글 보기나 검색을 통해서 keyword를 갱신하고 새로운 데이터를 받아오면 1페이지를 보여주도록 해야하는데, Material-table
컴포넌트 내부에서 자체적으로 유지하고 있는 상태값인 pageIndex
는 페이지네이션 컴포넌트의 버튼을 클릭했을 때만 변경된다.(아래 이미지의 아이콘들..)
즉, 내가 추가한 버튼들(Search, 전체글보기 등)을 클릭하고 내가 스토어에 만들어둔 상태값(pageNumber)를 변경해봤자 아무런 효과가 없단 것이다. 🤦🏻
예를 들어서 3페이지로 이동했다가 전체글 보기 버튼을 클릭하면 스토어의 상태값 pageNumber는 0으로 갱신될지라도 여전히 페이지네이션은 3페이지를 가리키고 있다. 내 스토어에서는 첫 페이지를 보여줄 것이라 예상하고 1~2페이지의 데이터만 생성하므로(3페이지를 만들 데이터는 없다.) 경고메세지 출력과 함께 아무런 데이터도 출력되지 않는다.
그래서 온갖 방법을 다 시도해보았는데
마지막으로 떠오른게 내가 관리하는 스토어의 상태는 유지하고, 외부 라이브러리로 생성한 테이블 부분만 강제로 unmount 했다가 다시 mount하는 것이었다. (테이블의 상태값이 초기화 될 것이므로 첫페이지를 보여줄 것이라 예상했다.)
그래서 강제로 unmount → mount 하는 방법을 열심히 찾아보았다. 😂
결론적으론 Board 컴포넌트의 id를 바꿈으로써 목표를 이룰 수 있었다.
검색어를 제거하고 전체글을 가져온다.
🔎 type.js
CHANGE_SHOWING_ALL_CONTENTS: 'BOARD/CHANGE_SHOWING_ALL_CONTENTS',
🔎 reducer.js
const initialState = {
pageNumber: 0,
pageSize: BOARD_PAGE_SIZE,
selectedData: [],
isModalOpen: false,
modalData: {},
isWriteModal: false,
isSearch: false, // 검색을 한 상태인지 판단
keyword: "", // 검색어
boardId: 0, // 위에서 언급한 강제 unmount - mount를 위한 장치
}
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,
isWriteModal: false,
modalData: {},
}),
[type.MODIFY_DATA]: (state, action) => ({
...state,
selectedData: action.payload,
}),
[type.CLICK_WRITE_BUTTON]: (state, action) => ({
...state,
isModalOpen: true,
isWriteModal: true,
}),
[type.CHANGE_SHOWING_ALL_CONTENTS]: (state, action) => ({
...state,
isSearch: false,
keyword: "",
boardId: (state.boardId === 0 ? 1 : 0),
pageNumber: action.payload.pageNumber,
pageSize: action.payload.pageSize,
selectedData: action.payload.selectedData
}),
}, initialState
)
새롭게 추가된 상태값은 다음과 같다.
하나의 액션으로 데이터를 가져오는 것까지 처리할 것이기 때문에 pageNumber, pageSize, selectedData도 갱신된다.
boardId: (state.boardId === 0 ? 1 : 0)
: 강제로 Board
컴포넌트를 제거하고 재생성하기 위해서 의미없이 아이디를 0과 1로 번갈아가며 갱신한다.
🔎 action.js
export const changeShowAllContents = (pageSize) => dispatch => {
const pageNumber = 0;
return get(pageNumber + 1, pageSize)
.then(response => {
const selectedData = getData(pageNumber, pageSize, response);
dispatch({
type: type.CHANGE_SHOWING_ALL_CONTENTS,
payload: {
pageNumber: pageNumber,
pageSize: pageSize,
selectedData: selectedData
}
})
}).catch(error => {
/* error control */
})
}
pageSize만 인수로 받아오고, pageNumber는 첫페이지인 0으로 고정한다. 액션 타입이 CHANGE_SHOWING_ALL_CONTENTS
인 것을 제외하면 반환하는 부분은 changePage
와 동일하다.
새로운 상태와 액션 생성 함수를 추가한다.
Board
컴포넌트에 key를 추가하고 전체글보기 버튼을 눌렀을 때 수행할 함수인 handleShowAllContentsButton
에 액션 생성 함수를 전달한다.
전체글 보기 버튼을 클릭했을 때 함수를 수행하도록 한다.
프로젝트를 시작하고 "전체글보기" 버튼을 클릭했을 때 콘솔에 찍히는 로그를 확인해본다. CAHNGE_SHOWING_ALL_CONTENTS
액션이 발생하면서 boardId가 토글(1 → 0)되고 가져온 데이터도 변경된 것을 확인할 수 있다. 물론, 화면에는 첫 페이지가 나타나야한다.
export function getForSearch(pageNumber, pageSize, searchType, keyword) {
return axios.get(DOMAIN+"/api/boards?page="+pageNumber+"&size="+pageSize+"&type="+searchType+"&keyword="+keyword);
}
searchType은 서버에서 Enum
타입으로 받으며 ALL, CONTENTS, TITLE, WRITER 네가지 종류가 있다. 일단은 ALL 타입으로 테스트하고 추후에 셀렉터를 추가하도록 하겠다.
🔎 type.js
KEYWORD_SEARCH: 'BOARD/KEYWORD_SEARCH',
ALL 타입으로 검색한 경우이다. 이 경우에는 제목 또는 내용에 키워드가 포함된 데이터를 반환한다.
🔎 reducer.js
상태값은 위의 전체글보기에서 사용한 것들을 쓰기때문에 추가할 것은 없다.
[type.KEYWORD_SEARCH]: (state, action) => ({
...state,
isSearch: true,
keyword: action.payload.keyword,
boardId: (state.boardId === 0 ? 1 : 0),
pageNumber: action.payload.pageNumber,
pageSize: action.payload.pageSize,
selectedData: action.payload.selectedData
})
검색한 경우임을 나타내기 위해 isSearch
에 true
값을 할당하고, keyword
를 저장한다. 키워드를 저장하는 이유는 페이지를 넘겨도 같은 키워드로 해당 페이지 데이터를 받아와야하기 때문이다. 나중에 searchType을 추가하면 이또한 상태로 관리할 것이다.
🔎 action.js
export const keywordSearch = (pageSize, searchType, keyword) => dispatch => {
const pageNumber = 0;
return getForSearch(pageNumber + 1, pageSize, searchType, keyword)
.then(response => {
const selectedData = getData(pageNumber, pageSize, response);
dispatch({
type: type.KEYWORD_SEARCH,
payload: {
keyword: keyword,
pageNumber: pageNumber,
pageSize: pageSize,
selectedData: selectedData
}
})
}).catch(error => {
/* error control */
})
}
boardApi에 새로 추가한 함수인 getForSearch를 이용해 데이터를 가져온다.
keywordSearch 액션 생성함수를 추가하고, Board 컴포넌트의 handleSearch
에 전달한다.
검색창에 입력되는 키워드는 상태값이 아니라 일반 변수로 관리할 것이다.
🤔 "로그인/회원가입 페이지에서는 상태값으로 관리하지 않았나??"
다음 코드로 실행해보자
export default function Board({keywordInStore, pageNumber, pageSize, selectedData, columns, data, accountId, handleChangePageNumber, handleChangePageSize, handleRowClick, handleWriteButtonClick, handleSearch, handleShowAllContentsButton}) {
const classes = useStyles();
const [searchKeyword, setSearchKeyword] = useState("");
return (
<MaterialTable
onChangePage={handleChangePageNumber}
onChangeRowsPerPage={handleChangePageSize}
icons={tableIcons}
columns={columns}
page={pageNumber}
data={selectedData}
pagenationType="stepped"
options={{
search: false,
paginationType: "stepped",
pageSize: BOARD_PAGE_SIZE
}}
onRowClick={(event, rowData) => {
handleRowClick(rowData);
}}
components={{
Toolbar: props => (
<Grid container>
<Grid item xs={6}>
{typeof accountId != 'undefined' && accountId != null ?
<Grid className={classes.margin}>
<Button color="primary" size="medium" variant="outlined"
onClick={() => handleWriteButtonClick()}>글쓰기</Button>
</Grid>
: null
}
</Grid>
<Grid item xs={6}>
<Grid container alignItems="center" justify="flex-end" direction="row">
<Grid className={classes.margin}>
<Grid container spacing={1} alignItems="flex-end">
<Grid item>
<SearchIcon/>
</Grid>
<Grid item>
<TextField id="input-with-icon-grid"
name="input-with-icon-grid"
autoComplete="input-with-icon-grid"
value={searchKeyword}
autoFocus
onChange={event => setSearchKeyword(event.target.value)}/>
</Grid>
<Grid>
<Button color="primary" size="medium" onClick={() => handleSearch(pageSize, "ALL", searchKeyword)}>Search</Button>
</Grid>
<Grid>
<Button color="secondary" size="medium"
onClick={() => handleShowAllContentsButton(pageSize)}>전체글
보기</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
),
}}
/>
);
}
문제를 단번에 알아챌 수 있을 것이다. 한글로 검색어를 입력하면 자모음이 모두 떨어지는 것을 볼 수 있다. 로그인 페이지와의 차이점이라하면 컴포넌트의 크기라고 생각되는데 재렌더링하는데 걸리는 시간이 길기때문에 포커스아웃과 포커스인의 격차가 생겨 이런 현상이 생기는 것이 아닐까 생각된다. (추측이다 🤔)
그래서 일반적인 변수로 선언하여 컴포넌트가 재렌더링되지 않도록 막고, "Search" 버튼을 클릭했을 때 그 값을 사용하도록 하였다.
export default function Board({keywordInStore, pageNumber, pageSize, selectedData, columns, data, accountId, handleChangePageNumber, handleChangePageSize, handleRowClick, handleWriteButtonClick, handleSearch, handleShowAllContentsButton}) {
const classes = useStyles();
let searchKeyword = "";
const setSearchKeyword = (value) => {
searchKeyword = value;
console.log(searchKeyword);
}
return (
<MaterialTable
onChangePage={handleChangePageNumber}
onChangeRowsPerPage={handleChangePageSize}
icons={tableIcons}
columns={columns}
page={pageNumber}
data={selectedData}
pagenationType="stepped"
options={{
search: false,
paginationType: "stepped",
pageSize: BOARD_PAGE_SIZE
}}
onRowClick={(event, rowData) => {
handleRowClick(rowData);
}}
components={{
Toolbar: props => (
<Grid container>
<Grid item xs={6}>
{typeof accountId != 'undefined' && accountId != null ?
<Grid className={classes.margin}>
<Button color="primary" size="medium" variant="outlined"
onClick={() => handleWriteButtonClick()}>글쓰기</Button>
</Grid>
: null
}
</Grid>
<Grid item xs={6}>
<Grid container alignItems="center" justify="flex-end" direction="row">
<Grid className={classes.margin}>
<Grid container spacing={1} alignItems="flex-end">
<Grid item>
<SearchIcon/>
</Grid>
<Grid item>
<TextField id="input-with-icon-grid"
placeholder={keywordInStore != "" ? "검색어: "+keywordInStore : "검색어를 입력하세요."}
onChange={event => setSearchKeyword(event.target.value)}/>
</Grid>
<Grid>
<Button color="primary" size="medium" onClick={() => handleSearch(pageSize, "ALL", searchKeyword)}>Search</Button>
</Grid>
<Grid>
<Button color="secondary" size="medium"
onClick={() => handleShowAllContentsButton(pageSize)}>전체글
보기</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
),
}}
/>
);
}
console.log로 변수를 출력하고 있으니 확인해보자.
console | 검색창 |
---|---|
"Search" 버튼을 클릭한 뒤에 placeholder로 이전 검색어가 출력되고, 게시판 목록이 바뀐것을 확인할 수 있다.
여기서 두가지 문제가 발견되는데
순서대로 해결해보도록 하겠다.
boardHelper의 getData
함수를 수정해주자.
수정 전 | 수정 후 |
---|---|
이전에는 첫페이지인지만 검사하여 true일 경우 가져온 데이터에 가짜 데이터를 pageSize만큼 덧붙여 리턴했다.
데이터가 없는 경우는 빈 배열을 반환하고, 전체 페이지(response.data.totalPages) 개수가 하나라면 뒤에 가짜 데이터를 붙이지 않아서 한 페이지만 구성하도록 한다.
아래는 수정된 코드로 실행한 모습이다. 🙋🏻
🔎 action.js
keywordSearch 액션 생성함수에서 keyword를 저장하고, isSearch를 true로 토글했기 때문에 이 상태값을 잘 사용하면 된다.
board store의 action 생성함수 중 changePage
를 변경해줄 것이다. 전체 코드는 아래와 같다.
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 => {
const selectedData = getData(pageNumber, pageSize, response);
dispatch({
type: type.CHANGE_PAGE,
payload: {
pageNumber: pageNumber,
pageSize: pageSize,
selectedData: selectedData
}
})
}).catch(error => {
/* error control */
})
}
분기문을 사용해 requestApi에 getForSearch 혹은 get을 적절하게 할당한다.
🔎 BoardController
변경한 액션 생성 함수에 맞춰 mapDispatchToProps
와 두 handle함수 내 changePage
에 전달해주는 인자를 변경한다.
실행해보자!! 🙋🏻 ("수정"이라는 단어로 검색했다.)
제목 혹은 내용에 검색어가 포함된 게시글만 출력하는 것을 볼 수 있다.
전체 코드는 github에서 확인 할 수 있습니다.