개인 개발 프로젝트/Lunch Box 앱

[Lunch Box] 10. 공지 화면

종범2 2019. 10. 13. 15:35

상단 세 번째 탭의  SALLABERS를 클릭하면 공지 화면으로 넘어간다. 공지 화면에서는 게시판 형태로 정보를 전달하고 편의를 위한 검색기능과 페이지 당 보여주는 게시물의 갯수를 변경하는 기능을 제공한다. 또한 여기서 제목을 클릭하면 세부 내용을 보여주는 화면으로 넘어가며 조회수가 1 증가한다. 각 게시물에서는 댓글을 작성할 수 있다.

 

Board.js에서는 리스트 형태로 받아오는 공지 정보를 보여줘야한다. 이때 게시판 형태로 정보를 제공해야하는데 이를 위한 component는 material-ui에서 제공하므로 이를 이용하였다. 공지 화면의 기본적인 뼈대는 material-ui의 tables를 참고하였다.

 

https://material-ui.com/components/tables/

 

Board.js

import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Table from '@material-ui/core/Table';
import TableHead from '@material-ui/core/TableHead';
import TableBody from '@material-ui/core/TableBody';
import TableRow from '@material-ui/core/TableRow';
import TableCell from '@material-ui/core/TableCell';
import TableFooter from '@material-ui/core/TableFooter';
import TablePagination from '@material-ui/core/TablePagination';
import { withStyles } from '@material-ui/core/styles';
import InputBase from '@material-ui/core/InputBase';
import SearchIcon from '@material-ui/icons/Search';
import Toolbar from '@material-ui/core/Toolbar';
import Media from 'react-media';
import myFireBase from '../../config/MyFireBase'
// Board total data
var totalBoardData = {};
// Storage and Database from firebase
const storageRef = (new myFireBase).storageRef;
const databaseRef = (new myFireBase).databaseRef;
// Style
const styles = theme => ({
  boardTopBackground: {
    zIndex: 1,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    width: '100%',
    height: '12rem',
    position: 'absolute'
  },
  boardTop: {
    height: '12em',
    width: '100%',
    position: 'relative',
  },
  boardTopText: {
    zIndex: 2,
    position: 'absolute',
    bottom: '3rem',
    left: '3rem',
    fontSize: "2rem",
    fontWeight: 'bold',
    color: 'white',
  },
  boardTopImg: {
    height: '12em',
    width: '100%',
    objectFit: 'cover',
    position: 'relative'
  },
  tableBody: {
    fontFamily: 'Noto Sans KR',
    fontSize: '1rem',
    color: '#494949',
    height: '1rem',
    textAlign: 'center',
    letterSpacing: 0
  },
  tableHeader: {
    fontFamily: 'Noto Sans KR',
    fontSize: '1rem',
    fontWeight: 'bold',
    color: '#494949',
    textAlign: 'center',
    height: '1rem',
    letterSpacing: 0
  },
  search: {
    fontFamily: 'Noto Sans KR',
    position: 'relative',
    borderRadius: theme.shape.borderRadius,
    borderWidth: '1px',
    borderColor: 'red'
  },
  searchIcon: {
    width: theme.spacing(7),
    height: '100%',
    position: 'absolute',
    pointerEvents: 'none',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  },
  inputRoot: {
    color: 'inherit',
  },
  inputInput: {
    padding: theme.spacing(1, 1, 1, 7),
    transition: theme.transitions.create('width'),
    width: '100%',
    [theme.breakpoints.up('sm')]: {
      width: 120,
      '&:focus': {
        width: 200,
      },
    },
  }
})
class Board extends Component {
  // Init
  constructor(props) {
    super(props);
    this.state = {
      imgSrcBoardTop:'',
      boardData: {},
      page: 0,
      rowsPerPage: 10,
      emptyRows: 0,
      searchKeyword: '',
    }
  }
  componentDidMount() {
    this.getBoardData();
    this.getImage();
  }
  // Get Image
  getImage () {
    storageRef.child('boardtop.jpg').getDownloadURL().then((url) => {
      this.setState({imgSrcBoardTop:url});
    })
  }
  // Get Data
  getBoardData() {
    databaseRef.child('board/').once('value').then(data=>{
      totalBoardData = data.val();
      this.setState({ boardData: totalBoardData.slice(this.state.page * this.state.rowsPerPage, this.state.page * this.state.rowsPerPage + this.state.rowsPerPage)});
    })
  }
  // Change searchKeyword with value
  handleChangeValue = (e) => {
    this.setState({ searchKeyword: e.target.value });
    let slicedData = totalBoardData.slice(this.state.page * this.state.rowsPerPage, this.state.page * this.state.rowsPerPage + this.state.rowsPerPage);
    let filteredData = slicedData.filter((b) => {
      return b.title.indexOf(e.target.value) > -1;
    });
    this.setState({ boardData: filteredData });
    this.setState({ emptyRows: this.state.rowsPerPage - filteredData.length });
  }
  // Change page with new page and refresh searchKeyword
  handleChangePage = (event, newPage) => {
    this.setState({ page: newPage });
    this.setState({ searchKeyword: '' });
    let slicedData  = totalBoardData.slice(newPage* this.state.rowsPerPage, newPage * this.state.rowsPerPage + this.state.rowsPerPage);
    this.setState({ boardData: slicedData})
    this.setState({ emptyRows: this.state.rowsPerPage - slicedData.length });
  }
  // Change rowPerPage with value and refresh page and searchKeyword
  handleChangeRowsPerPage = (event) => {
    this.setState({ page: 0 });
    this.setState({ rowsPerPage: parseInt(event.target.value, 10) });
    this.setState({ searchKeyword: '' });
    let slicedData = totalBoardData.slice(0, parseInt(event.target.value, 10));
    this.setState({ boardData: slicedData});
    this.setState({ emptyRows: parseInt(event.target.value, 10) - slicedData.length });
  }
  // Increse Hit
  increaseHit= (id,hit) => {
    databaseRef.child('board/'+(id-1)).update({
      hit: hit+1
    })
  }
  render() {
    // Set classes
    const { classes } = this.props;
    // Return
    return (
      <div>
        <div className={classes.boardTop}>
          <div className={classes.boardTopBackground}></div>
          <img className={classes.boardTopImg} src={this.state.imgSrcBoardTop} />
          <div className={classes.boardTopText}>
            Notices of Sallab
          </div>
        </div>
        <div>
          {/* Search tool bar */}
          <Toolbar>
            <div style={{ 'width': '100%' }} />
            <div className={classes.search}>
              <div className={classes.searchIcon}><SearchIcon /></div>
              <InputBase placeholder="Search" classes={{ root: classes.inputRoot, input: classes.inputInput, }} inputProps={{ 'aria-label': 'search' }}
                name="searchKeyword" value={this.state.searchKeyword} onChange={this.handleChangeValue} style={{ "fontFamily": "Noto Sans KR" }} />
            </div>
          </Toolbar>
          <Media query="(max-width: 960px)">
            {matches =>
              matches ? (
                // When width is less then 960px 
                <Table className={classes.table}>
                  <TableHead>
                    <TableRow>
                      <TableCell className={classes.tableHeader} style={{ width: "5%" }}>#</TableCell>
                      <TableCell className={classes.tableHeader} style={{ width: "70%" }}>Title</TableCell>
                      <TableCell className={classes.tableHeader} style={{ width: "25%" }}>Created by</TableCell>
                    </TableRow>
                  </TableHead>
                  <TableBody>
                    {Object.keys(this.state.boardData).map(idx => {
                      const b = this.state.boardData[idx];
                      return (<TableRow key={b.id} component={Link} to={{
                        pathname: "/main/board/detail",
                        state: {
                          id: b.id,
                          title: b.title,
                          content: b.content,
                          createdby: b.createdby,
                          createdDate: b.createdDate,
                          hit: b.hit,
                        }
                      }} onClick={this.increaseHit.bind(this,b.id,b.hit)}>
                        <TableCell className={classes.tableBody} >{b.id}</TableCell>
                        <TableCell className={classes.tableBody} >{b.title}</TableCell>
                        <TableCell className={classes.tableBody} >{b.createdby}</TableCell>
                      </TableRow>)
                    })}
                    {this.state.emptyRows > 0 && (
                      <TableRow style={{ height: 46.34 * this.state.emptyRows }}>
                        <TableCell colSpan={3} />
                      </TableRow>
                    )}
                  </TableBody>
                </Table>
              ) :
                (
                  // When width is bigger then 960px 
                  <Table className={classes.table}>
                    <TableHead>
                      <TableRow>
                        <TableCell className={classes.tableHeader} style={{ width: "5%" }}>#</TableCell>
                        <TableCell className={classes.tableHeader} style={{ width: "70%" }}>Title</TableCell>
                        <TableCell className={classes.tableHeader} style={{ width: "10%" }}>Created by</TableCell>
                        <TableCell className={classes.tableHeader} style={{ width: "10%" }}>Created Date</TableCell>
                        <TableCell className={classes.tableHeader} style={{ width: "5%" }}>Hit</TableCell>
                      </TableRow>
                    </TableHead>
                    <TableBody>
                      {
                        Object.keys((this.state.boardData)).map(idx => {
                        const b = this.state.boardData[idx];
                        return (<TableRow key={b.id} component={Link} to={{
                          pathname: "/main/board/detail",
                          state: {
                            id: b.id,
                            title: b.title,
                            content: b.content,
                            createdby: b.createdby,
                            createdDate: b.createdDate,
                            hit: b.hit,
                          }
                        }} onClick={this.increaseHit.bind(this,b.id,b.hit)}>
                          <TableCell className={classes.tableBody} >{b.id}</TableCell>
                          <TableCell className={classes.tableBody} >{b.title}</TableCell>
                          <TableCell className={classes.tableBody} >{b.createdby}</TableCell>
                          <TableCell className={classes.tableBody} >{b.createdDate}</TableCell>
                          <TableCell className={classes.tableBody} >{b.hit}</TableCell>
                        </TableRow>)
                      })}
                      {this.state.emptyRows > 0 && (
                        <TableRow style={{ height: 50.91 * this.state.emptyRows }}>
                          <TableCell colSpan={5} />
                        </TableRow>
                      )}
                    </TableBody>
                  </Table>
                )
            }
          </Media>
          {/* Pagination */}
          <TableFooter>
            <TableRow >
              <TablePagination
                rowsPerPageOptions={[10, 25]}
                colSpan={5}
                count={totalBoardData.length}
                rowsPerPage={this.state.rowsPerPage}
                page={this.state.page}
                SelectProps={{
                  inputProps: { 'aria-label': 'rows per page' },
                  native: true,
                }}
                onChangePage={this.handleChangePage}
                onChangeRowsPerPage={this.handleChangeRowsPerPage}
              />
            </TableRow>
          </TableFooter>
        </div>
      </div>
    );
  }
}

export default withStyles(styles)(Board);

이전 화면들과 유사하게 material-ui의 withStyles를 이용하여 스타일을 적용하였고 이미지와 데이터 모두 firebase에서 받아왔다.

 

react-media의 Media component

공지 화면에서는 화면에 따라 보여주어야 할 component의 형태가 다르다. 큰 화면에서는 id, 제목, 작성자, 작성날짜, 조회수를 보여주고 작은 화면에서는 id, 제목, 작성자를 보여준다. 이런 경우에는 react-media의 Media component를 사용한다. 위와 같이 코드를 작성하면 화면 조건에 따라 다른 component를 보여줄 수 있다. 조건을 더 다양하게 할 수 있지만 이 프로젝트에서는 일관되게 960px을 기준으로 설정했다. 이에 관해서는 다음을 참고하였다.

 

https://github.com/ReactTraining/react-media

 

Search 기능

검색창(InputBase component)에 특정 문자를 입력하면 해당 문자를 포함한 데이터만 보이도록 해야한다. 이를 위해 onChange 이벤트에 handleChangeValue 메소드를 작성한다. 이 메소드에서는 특정 문자를 입력하거나 수정할때마다 현재 보이는 데이터에서 해당 문자를 포함한 데이터만 필터링하여 boardData에 저장한다. 또한 이를 필터링하고 남은 부분은 공백으로 두어야하기 때문에 emptyRow에는 비어있는 행의 수를 저장한다. 따라서 검색창에 특정 문자를 입력할때마다 State가 수정되고 이때마다 Table component에서는 새롭게 필터링된 데이터를 보여주고 남은 부분에는 공백을 보여준다.

 

페이지 전환 기능

페이지 밑에는 다음페이지로 넘어가거나 이전페이지로 넘어가는 버튼이 존재한다. 실제 페이지를 넘기진 않고 현재 보여주는 데이터의 다음 데이터를 보여준다. 예를 들어 총 90개의 데이터중에 현재 페이지에서 10개의 데이터를 보여준다면 1부터 10까지의 데이터를 보여주다가 11부터 20까지의 데이터를 보여주어야한다. 이를 위해 onChangePage 이벤트에 handleChangePage 메소드를 작성한다. onChangePage는 TablePagination component에서 페이지 전환 버튼을 눌렀을때를 의미한다. 이 메소드에서는 페이지가 전환되었으므로 searchKeyword를 빈칸으로 초기화하고 다음 데이터를 보여준다. 마찬가지로 여기서도 emptyRow에 비어있는 행의 수를 저장한다.

 

페이지당 보여주는 행 갯수 변경 기능

페이지 밑에는 페지이당 보여주는 행 갯수를 변경하는 select box가 존재한다. 이 역시 새로운 페이지를 보여주진 않고 현재 보여주는 데이터의 갯수를 변경한다. 이를 위해 onChangeRowsPerPage 이벤트에 handleChangeRowsPerPage 메소드를 작성한다. onChangeRowsPerPage  TablePagination component에서 select box 변경을 의미한다. 이 메소드에서는 페이지를 첫번째 페이지로 초기화하고 searchKeyword도 빈칸으로 초기화한다. 그리고 rowsPerPage에 설정된 갯수를 저장하고 갯수에 맞는 데이터를 보여준다.

 

조회수 증가 기능

특정 행을 클릭하면 다음 화면에서 상세 정보를 보여준다. 이때 클릭한 행의 조회수를 증가시키기 위해 모든 행의 onlick 이벤트에 increaseHit 메소드를 작성하였다. 이 메소드에서는 행의 id와 현재 조회수인 hit 정보를 인자로 받아 해당하는 id를 가진 데이터의 hit값을 1 증가시킨다. Firebase Database에 접근하기 위해 databaseRef에 myFireBase component의 databaseRef를 저장했다. 정보를 update하는 기능은 다음을 참고하였다.

 

https://wayhome25.github.io/firebase/2017/02/16/06-firebase_modify_delete/
https://firebase.google.com/docs/database/web/save-data

 

상세 페이지 넘어가기 기능

특정 행을 클릭하면 행의 조회수를 증가시킨 후 상세 페이지로 넘어간다. 각 행은 TableRow component인데 이전에 상단 탭과 유사하게 component에 Link component를 전달하고 Link component의 to에 pathname, state를 전달하도록했다. Link의 to에는 pathname, state, query, hashname을 전달할 수 있는데 여기서는 두 가지만 전달하였다. 이렇게 설정하면 pathname에 입력한 /main/board/detail로 라우팅된다. 해당 라우팅은 BoardDetail component로 설정하였으므로 특정 행을 클릭하면 BoardDetail component를 보여주고 state로 전달받은 정보를 이용하여 클릭한 행의 상세 정보를 BoardDetail component에서 보여준다. BoardDetail component에서는 전달받은 정보를 props.location.state에서 조회한다. Link 정보는 다음을 참고하였다.

 

https://reacttraining.com/react-router/web/api/Link/to-string

 

 

 

 다음 글에서는 BoardDetail에서 전달받은 정보를 어떻게 보여주는지 설명하겠다.