[프로젝트] 게시판 front 리펙토링 (7) 검색하기

rin·2020년 6월 11일
2

React

목록 보기
15/16
post-thumbnail
post-custom-banner

목표
1. UI 변경하기
2. 검색 로직 완성하기
3. 버그 수정하기

UI 변경

Board

검색창을 활성화하기 전에 UI를 살짝 고쳐보도록 하겠다.
사실 딱히 필요한 작업은 아니었지만.. MaterialTable에 포함된 서치는 이미 받아온 데이터에서 검색을 하기때문에 의도한 바와 다르므로 자체적으로 서치를 위한 UI를 달게 되었다.

바로 이전 글에서 사용했던 componentsToolbar를 이용해 검색용 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>
                )
            }}
        />
    );
}

그럼 게시판 타이틀과 로그인/로그아웃 버튼은 어디로 갔을까?🤔

Topbar

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>
    );
}

BoardContainer

Board 위에 Topbar를 추가해준다.

변경된 UI의 최종 모습은 다음과 같다.

+ 추가

Board 컴포넌트에서 Search 버튼 아래에 버튼을 하나 더 추가한다.

검색 추가하기

삽질..

외부 라이브러리를 사용하다보니 예상치못한 문제가 생겨서 한참을 삽질하다왔다. 😑
전체글 보기나 검색을 통해서 keyword를 갱신하고 새로운 데이터를 받아오면 1페이지를 보여주도록 해야하는데, Material-table 컴포넌트 내부에서 자체적으로 유지하고 있는 상태값인 pageIndex페이지네이션 컴포넌트의 버튼을 클릭했을 때만 변경된다.(아래 이미지의 아이콘들..)

즉, 내가 추가한 버튼들(Search, 전체글보기 등)을 클릭하고 내가 스토어에 만들어둔 상태값(pageNumber)를 변경해봤자 아무런 효과가 없단 것이다. 🤦🏻

예를 들어서 3페이지로 이동했다가 전체글 보기 버튼을 클릭하면 스토어의 상태값 pageNumber는 0으로 갱신될지라도 여전히 페이지네이션은 3페이지를 가리키고 있다. 내 스토어에서는 첫 페이지를 보여줄 것이라 예상하고 1~2페이지의 데이터만 생성하므로(3페이지를 만들 데이터는 없다.) 경고메세지 출력과 함께 아무런 데이터도 출력되지 않는다.

그래서 온갖 방법을 다 시도해보았는데

  1. 직접 상태값에 접근할 수 없다.
  2. 외부에서 상태값을 바꿀 수 있는 오버라이딩 함수를 지원하지 않는다.
  3. useRef()가 사용이 안된다.

마지막으로 떠오른게 내가 관리하는 스토어의 상태는 유지하고, 외부 라이브러리로 생성한 테이블 부분만 강제로 unmount 했다가 다시 mount하는 것이었다. (테이블의 상태값이 초기화 될 것이므로 첫페이지를 보여줄 것이라 예상했다.)

그래서 강제로 unmount → mount 하는 방법을 열심히 찾아보았다. 😂
결론적으론 Board 컴포넌트의 id를 바꿈으로써 목표를 이룰 수 있었다.

전체글 보기

검색어를 제거하고 전체글을 가져온다.

Store

🔎 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
)

새롭게 추가된 상태값은 다음과 같다.

  • isSearch : 검색을 한 상태인지 판단
  • keyword : 검색어
  • boardId : 위에서 언급한 강제 unmount - mount를 위한 장치

하나의 액션으로 데이터를 가져오는 것까지 처리할 것이기 때문에 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와 동일하다.

BoardContainer

새로운 상태와 액션 생성 함수를 추가한다.
Board 컴포넌트에 key를 추가하고 전체글보기 버튼을 눌렀을 때 수행할 함수인 handleShowAllContentsButton에 액션 생성 함수를 전달한다.

Board

전체글 보기 버튼을 클릭했을 때 함수를 수행하도록 한다.

프로젝트를 시작하고 "전체글보기" 버튼을 클릭했을 때 콘솔에 찍히는 로그를 확인해본다. CAHNGE_SHOWING_ALL_CONTENTS 액션이 발생하면서 boardId가 토글(1 → 0)되고 가져온 데이터도 변경된 것을 확인할 수 있다. 물론, 화면에는 첫 페이지가 나타나야한다.

검색하기

boardApi

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 타입으로 테스트하고 추후에 셀렉터를 추가하도록 하겠다.

Store

🔎 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
})

검색한 경우임을 나타내기 위해 isSearchtrue값을 할당하고, 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를 이용해 데이터를 가져온다.

BoardContainer

keywordSearch 액션 생성함수를 추가하고, Board 컴포넌트의 handleSearch에 전달한다.

Board

검색창에 입력되는 키워드는 상태값이 아니라 일반 변수로 관리할 것이다.

🤔 "로그인/회원가입 페이지에서는 상태값으로 관리하지 않았나??"

다음 코드로 실행해보자

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로 이전 검색어가 출력되고, 게시판 목록이 바뀐것을 확인할 수 있다.

여기서 두가지 문제가 발견되는데

  • 첫째는 분명 데이터는 2개가 다인데 다음 페이지(2)가 있는 것으로 보여지는 것이고,
  • 둘째는 다음 페이지를 누르면 검색어와 상관없이 전체 데이터의 2페이지가 보여지는 것이다.

순서대로 해결해보도록 하겠다.

버그 수정

한페이지 이하의 데이터만 존재하는 경우

boardHelpergetData 함수를 수정해주자.

수정 전수정 후

이전에는 첫페이지인지만 검사하여 true일 경우 가져온 데이터에 가짜 데이터를 pageSize만큼 덧붙여 리턴했다.
데이터가 없는 경우는 빈 배열을 반환하고, 전체 페이지(response.data.totalPages) 개수가 하나라면 뒤에 가짜 데이터를 붙이지 않아서 한 페이지만 구성하도록 한다.

아래는 수정된 코드로 실행한 모습이다. 🙋🏻

  1. 출력할 데이터가 없는 경우
  2. 출력할 데이터가 한 페이지 이하인 경우

검색한 경우에 페이지 전환

🔎 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에서 확인 할 수 있습니다.

profile
🌱 😈💻 🌱
post-custom-banner

0개의 댓글