반응형

 

 

목차 돌아가기:

https://binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

sonarlint extension을 사용해서 자꾸 빨간 줄이 그어져 있는 게 신경쓰여서 해결하려 한다.

 

 

원래 views/Article.js 에 작성일시 부분은 이렇게 되어있었다.

 

<div><span>작성일시: </span><span>{(article.insertDate) ? new Date(article?.insertDate).toLocaleString() : ""}</span></div>

 

sonarlint warning? error? problem 으로는 다음과 같은 문장이 떴는데

 

Extract this nested ternary operation into an independent statement.

 

삼항연산자 아무데서나 쓰지마!!! 이런 느낌이라 말을 듣기로 했음

 

 

그래서 그냥 null이나 undefined 인 경우 아예 렌더링 되지 않도록 바꿨다.

 

<div><span>작성일시: </span><span>{(article?.insertDate) && new Date(article.insertDate).toLocaleString()}</span></div>

 

 

Board와 ArticleList 에서도 같은 problem warning이 일어나는데

 

다음 글을 읽고 그 부분은 고치지 않고 두기로 하였다, stupid하다는 의견에 동의한다,

 

 

https://stackoverflow.com/questions/46272156/how-can-i-avoid-nested-ternary-expressions-in-my-code

 

How can I avoid nested ternary expressions in my code?

I have code like this. How can I write it in cleaner, more elegant way using functional programming in JavaScript? I want to get rid of nested ternary expressions. Any ideas? props => ({ ...

stackoverflow.com

 

 

 

That being said, you should disable [no-nested-ternary] in your linter. It's kind of stupid that your linter thinks that nested conditionals are bad.

 

 

 

 

 

목차 돌아가기:

https://binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

 

 

목차 돌아가기:

https://binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

 

textarea 태그에서 줄바꿈이 포함된 글은 \n 을 포함하여 서버에 저장된다.

 

탭의 경우 \t 로 들어간다.

 

html editor 를 이용하게 되면 자동으로 replace 를 해줄테니 이러한 문제가 생기지 않겠지만

 

v2 소스는 아무런 스타일도 다른 UI 도 사용하지 않았기 때문에 string 과 함께 있는 태그의 경우 리액트에서 태그로 읽히지 않고 string과 함께 렌더링될 수 있다.

 

 

 

만들면서 긴 문장이나 문단으로 테스트하지 않았기 때문에 코드 작성시에는 이런 문제를 생각하지 못하였다.

 

 

구독자 myurm 님 덕분에 발견할 수 있었다.

 

 

 

이슈 해결을 위해 아래 사이트를 참고했다.

 

 

https://velopert.com/1896

 

[React.js] Tip: string 형태의 html을 렌더링하기, newline(\n) 을 BR 태그로 변환하기 | VELOPERT.LOG

React 매뉴얼의 “Dangerously Set innerHTML” 페이지에 따르면, React에서는 cross-site scripting (XSS) 공격을 막기 위하여, 렌더링 메소드 내부에서 html 태그가 담겨있는 string 형태를 렌더링하면, 태그가 안

velopert.com

 

 

바뀐 views/Article.js 의 소스는 아래와 같다.

 

// Article.js

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import Comment from './Comments';
import { articleActions } from '../slices/articleSlice';

function Article() {
    const params = useParams();
    const { article, status, statusText } = useSelector((state) => state.articleReducer);
    const boardList = useSelector((state) => state.boardReducer.boardList);
    const dispatch = useDispatch();
    const history = useHistory();

    function onClickUpdateButton() {
        history.push(`/update/${params?.articleId ?? 0}`);
    }

    function onClickDeleteButton() {
        if (!window.confirm("삭제하시겠습니까?")) return false;
        dispatch(articleActions.deleteArticle());
    }

    useEffect(() => {
        dispatch(articleActions.getArticle(params?.articleId ?? 0));
    }, [dispatch, params?.articleId]);
    return (
        <>
            {
                status === 200 ?
                    <>
                        <div>
                            <div>
                                <div>
                                    <div>
                                        <button onClick={onClickUpdateButton}>수정</button>
                                    </div>
                                    <div>
                                        <button onClick={onClickDeleteButton}>삭제</button>
                                    </div>
                                </div>
                                <div>
                                    <span>게시판: </span>
                                    <span>
                                        {
                                            boardList.length > 0 &&
                                            boardList.find((board) => board.id === parseInt(article?.boardId))?.name
                                        }
                                    </span>
                                </div>
                                <div>
                                    <div><span>제목: </span><span>{article?.title ?? ""}</span></div>
                                    <div><span>조회수: </span><span>{article?.views ?? ""}</span></div>
                                    <div><span>작성일시: </span><span>{(article.insertDate) ? new Date(article?.insertDate).toLocaleString() : ""}</span></div>
                                    <div><span>내용: </span><span>{article?.content?.split("\n")?.map(line => <span>{line}<br/></span>)}</span></div>
                                </div>
                            </div>
                            <div>
                                <Comment articleId={params?.articleId ?? 0} />
                            </div>
                        </div>
                    </>
                :
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
            }
        </>
    );
}

export default Article;

 

 

<span>{article?.content?.split("\n")?.map(line => <span>{line}<br/></span>)}</span>

 

 

방식은 \n 을 기준으로 문장을 split 하여 배열로 변환한다.

 

그리고 map 함수를 이용하여 <br /> 태그와 함께 뿌려주는 것이다.

 

 

 

목차 돌아가기:

https://binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

목차 돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

이건 원래 계획에 없었는데

 

 

근데 깨끗한 db상태에서 시작하려면 필요할 것 같아 결국 만들었다.

 

이제껏 해온 saga, redux, CRUD의 총집합이다. (react-router가 없군..!)

 

 

board와 code만 db에 남기면 되기에 필요없는 사람들은

articles와 comments 만 빈 배열로 남기면 된다.

 

 

그리고 만들어야 할 컴포넌트는 4개이고 slice와 saga는 codeSlice, codeSaga, 수정이 필요한 곳은 boardSlice, boardSaga인데 만들면서도 왔다갔다한 과정이 많아서

 

최종 소스를 갔다놓고 설명하려 한다.

 

만드는 과정은 이전 과정과 거의 비슷해서 useState를 리스트에 적용한 부분만 빼면 나머지 부분은 스스로 만들기 어렵지 않을 것이다.

오히려 지금의 소스보다 더 깔끔하게 만들 수도 있을 것이다.

 

 

1. slice

 

// boardSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "board";

const initialState = {
    boardList: [],
    status: 0,
    statusText: "Loading"
};

const reducers = {
    getBoardList: (state, action) => {},
    getBoardListSuccess: (state, action) => {
        state.boardList = action.payload?.data ?? [];
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    getBoardListFail: (state, action) => {
        state.boardList = initialState.boardList
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    getBoard: (state, action) => {},  //추가됐으나 사용 안함
    getBoardSuccess: (state, action) => {},  //추가됐으나 사용 안함
    getBoardFail: (state, action) => {},  //추가됐으나 사용 안함

    postBoard: (state, action) => {}, //추가 - 게시판 신규 등록
    postBoardSuccess: (state, action) => {}, //추가
    postBoardFail: (state, action) => {}, //추가

    putBoard: (state, action) => {}, //추가 - 게시판 개별 수정
    putBoardSuccess: (state, action) => {}, //추가
    putBoardFail: (state, action) => {}, //추가

    deleteBoard: (state, action) => {}, //추가 - 게시판 개별 삭제
    deleteBoardSuccess: (state, action) => {}, //추가
    deleteBoardFail: (state, action) => {}, //추가
};

const boardSlice = createSlice({
    name,
    initialState,
    reducers
});

export const boardReducer = boardSlice.reducer;
export const boardActions = boardSlice.actions;

 

설정 만들면서 의도하던 건 게시판 목록 전체를 불러와서 수정된 게시판들을 한꺼번에 저장하려 했으나 배열 put을 이용하여 수정하는 부분에서 실패하여 결국 개별 수정이 되었다.

 

다중 건 수정이 불가하다면 삭제도 마찬가지로 다중 건은 불가능할 거 같아 결국 개별 수정 및 개별 삭제 방향으로 바뀌게 됐다.

 

 

// codeSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "Code";

const initialState = {
    codeList: [],
    status: 0,
    statusText: "Loading"
};

const reducers = {
    getCodeList: (state, action) => {},
    getCodeListSuccess: (state, action) => {
        state.codeList = action.payload?.data ?? [];
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    getCodeListFail: (state, action) => {
        state.codeList = initialState.codeList
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    getCode: (state, action) => {}, // 추가됐으나 사용 안 함
    getCodeSuccess: (state, action) => {}, // 추가됐으나 사용 안 함
    getCodeFail: (state, action) => {}, // 추가됐으나 사용 안 함

    postCode: (state, action) => {}, // 추가
    postCodeSuccess: (state, action) => {}, // 추가
    postCodeFail: (state, action) => {}, // 추가

    putCode: (state, action) => {}, // 추가
    putCodeSuccess: (state, action) => {}, // 추가
    putCodeFail: (state, action) => {}, // 추가

    deleteCode: (state, action) => {}, // 추가
    deleteCodeSuccess: (state, action) => {}, // 추가
    deleteCodeFail: (state, action) => {}, // 추가
};

const codeSlice = createSlice({
    name,
    initialState,
    reducers
});

export const codeReducer = codeSlice.reducer;
export const codeActions = codeSlice.actions;

 

코드도 게시판과 마찬가지로 개별 수정 및 개별 삭제 방향으로 진행한다.

 

 

2. 컴포넌트

 

 

2. (0) 설정 * 위치는 /src/views 이다.

 

// src/views/Control.js

import React, { useState } from 'react';
import CreateBoard from './components/CreateBoard';
import CreateCode from './components/CreateCode';
import UpdateBoardList from './components/UpdateBoardList';
import UpdateCodeList from './components/UpdateCodeList';

function Control() {
    const [ showCreateCode, setShowCreateCode ] = useState(false);
    const [ showCreateBoard, setShowCreateBoard ] = useState(false);
    const [ showUpdateCodeList, setShowUpdateCodeList ] = useState(false);
    const [ showUpdateBoardList, setShowUpdateBoardList ] = useState(false);
    
    function onClickCreateCodeButton() {
        (showCreateCode) ? setShowCreateCode(false) : setShowCreateCode(true);
    }

    function onClickCreateBoardButton() {
        (showCreateBoard) ? setShowCreateBoard(false) : setShowCreateBoard(true);
    }

    function onClickUpdateCodeList() {
        (showUpdateCodeList) ? setShowUpdateCodeList(false) : setShowUpdateCodeList(true);
    }

    function onClickUpdateBoardList() {
        (showUpdateBoardList) ? setShowUpdateBoardList(false) : setShowUpdateBoardList(true);
    }

    return (
        <div>
            <div>
                <span>설정</span>
            </div>
            <div>
                <div>
                    <div>
                        <div>
                            <button onClick={onClickCreateCodeButton}>새 코드</button>
                        </div>
                        <div>
                            {
                                showCreateCode && <CreateCode setShowCreateCode={setShowCreateCode} />
                            }
                        </div>
                    </div>
                    <div>
                        <div>
                            <button onClick={onClickUpdateCodeList}>코드 목록 수정</button>
                        </div>
                        <div>
                            {
                                showUpdateCodeList && <UpdateCodeList setUpdateCodeList={setShowUpdateCodeList} />
                            }
                        </div>
                    </div>
                </div>
                <div>
                    <div>
                        <div>
                            <button onClick={onClickCreateBoardButton}>새게시판</button>
                        </div>
                        <div>
                            {
                                showCreateBoard && <CreateBoard setShowCreateBoard={setShowCreateBoard} />
                            }
                        </div>
                    </div>
                    <div>
                        <div>
                            <button onClick={onClickUpdateBoardList}>게시판 목록 수정</button>
                        </div>
                        <div>
                            {
                                showUpdateBoardList && <UpdateBoardList setUpdateBoardList={setShowUpdateBoardList} />
                            }
                        </div>
                    </div>
                </div>
            </div>
        </div>
    );
}

export default Control;

 

설정 내 새게시판, 새코드, 게시판수정, 코드수정은 Link 를 이용하지 않고 Control 화면에서 true/false로 컴포넌트 렌더링 여부를 정해주는 방식으로 만들었다.

 

const [ showCreateCode, setShowCreateCode ] = useState(false);
const [ showCreateBoard, setShowCreateBoard ] = useState(false);
const [ showUpdateCodeList, setShowUpdateCodeList ] = useState(false);
const [ showUpdateBoardList, setShowUpdateBoardList ] = useState(false);

 

그래서 버튼을 누를 때마다 화면에서 입력폼이 show / hide 반복될 것이다.

 

그리고 등록/수정/삭제가 완료된 뒤 입력폼을 다시 안 보이게 할지를 saga에서 설정할 수도 있을 것 같아서

컴포넌트마다 props로 true/false 를 set해주는 메서드를 넘겨주었다.

 

showCreateCode && <CreateCode setShowCreateCode={setShowCreateCode} />
showUpdateCodeList && <UpdateCodeList setUpdateCodeList={setShowUpdateCodeList} />
showCreateBoard && <CreateBoard setShowCreateBoard={setShowCreateBoard} />
showUpdateBoardList && <UpdateBoardList setUpdateBoardList={setShowUpdateBoardList} />

 

 

 

 

2. (1) 게시판 신규 등록 * 위치는 /src/views/components 이다.

// src/views/components/CreateBoard.js

import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { boardActions } from '../../slices/boardSlice';
import { codeActions } from '../../slices/codeSlice';

function CreateBoard({ setShowCreateBoard }) {
    const { codeList, codeStatus, codeStatusText } = useSelector(
        (state) => ({
            codeList: state.codeReducer.codeList,
            codeStatus: state.codeReducer.status,
            codeStatusText: state.codeReducer.statusText
    }));
    const [ board, setBoard ] = useState({});
    const dispatch = useDispatch();

    function onChangeArticle(e) {
        setBoard({
            ...board,
            [e.currentTarget.name]: e.currentTarget.value
        });
    }

    function onClickSubmitButton() {
        if (board?.name)
        {
            dispatch(boardActions.postBoard({ board, setShowCreateBoard }));
        } else {
            alert("게시판이름은 필수값입니다.");
        }
    }

    useEffect(() => {
        dispatch(codeActions.getCodeList());
    }, [dispatch]);

    return (
        <>
            {
                codeStatus === 200 ? 
                codeList.length > 0 ?
                    <div>
                        <div>
                            <span>게시판 명: </span>
                            <input name="name" onChange={onChangeArticle} />
                        </div>
                        <div>
                            <span>사용 코드: </span>
                            <select name="code" onChange={onChangeArticle} >
                                <option value="">선택</option>
                                {
                                    codeList.map((code) =>(
                                        <option value={code?.value}>{code?.desc ?? ""}</option>
                                    ))
                                }
                            </select>
                        </div>
                        <div>
                            <button onClick={onClickSubmitButton} >등록</button>
                        </div>
                    </div>
                :
                    <div>
                        코드등록이 필요합니다.
                    </div>
                : 
                <div>
                    <div>
                        <span>{codeStatus}</span>
                    </div>
                    <div>
                        <span>{codeStatusText}</span>
                    </div>
                </div>
                
            }
        </>
    );
}

export default CreateBoard;

 

코드를 보면

 

// CreateBoard.js

const { codeList, codeStatus, codeStatusText } = useSelector(
    (state) => ({
        codeList: state.codeReducer.codeList,
        codeStatus: state.codeReducer.status,
        codeStatusText: state.codeReducer.statusText
}));

 

나중에 board 등록/수정/삭제의 서버 return값을 봐야 할 때 boardReducer의 status와 statusText를 쓰게 되면 codeReducer의 statust와 statusText와 이름이 겹칠 수도 있을 것 같아서 useSelector 부분에서 아예 이름을 바꿔 state를 들고 왔다.

그래서 codeReducer의 status 와 statusText는 CreateBoard 컴포넌트에서 codeStatus, codeStatusText 변수에 담겨 사용된다.

 

 

// CreateBoard.js

useEffect(() => {
    dispatch(codeActions.getCodeList());
}, [dispatch]);

 

 

코드리스트를 목록에서 다시 부르는 이유는 설정부분에서 코드가 실시간으로 수정되거나 신규로 등록되거나 삭제될 수 있기 때문에 이를 반영하고자 함이다.

 

조회된 codeList는 위의 useSelector의 구독기능으로 인해 조회할 때마다 계속 업데이트될 것이다.

 

 

// CreateBoard.js

const [ board, setBoard ] = useState({});

function onChangeArticle(e) {
    setBoard({
        ...board,
        [e.currentTarget.name]: e.currentTarget.value
    });
}

<div>
    <span>게시판 명: </span>
    <input name="name" onChange={onChangeArticle} />
</div>
<div>
    <span>사용 코드: </span>
    <select name="code" onChange={onChangeArticle} >
        <option value="">선택</option>
        {
            codeList.map((code) =>(
                <option value={code?.value}>{code?.desc ?? ""}</option>
            ))
        }
    </select>
</div>

 

게시판 신규 등록 폼의 입력 부분은 useState와 onChange이벤트를 이용하였다.

setBoard로 change value를 반영+set 해주는데

객체형태로도 바로 set할 수 있어서 { ...board, [e.currentTarget.name]: e.currentTarget.value } 를 이용했다.

 

{
    codeStatus === 200 ? 
    codeList.length > 0 ?
        <div>
            <div>
                <span>게시판 명: </span>
                <input name="name" onChange={onChangeArticle} />
            </div>
            <div>
                <span>사용 코드: </span>
                <select name="code" onChange={onChangeArticle} >
                    <option value="">선택</option>
                    {
                        codeList.map((code) =>(
                            <option value={code?.value}>{code?.desc ?? ""}</option>
                        ))
                    }
                </select>
            </div>
            <div>
                <button onClick={onClickSubmitButton} >등록</button>
            </div>
        </div>
    :
        <div>
            코드등록이 필요합니다.
        </div>
    : 
    <div>
        <div>
            <span>{codeStatus}</span>
        </div>
        <div>
            <span>{codeStatusText}</span>
        </div>
    </div>
    
}

 

JSX 의 경우 아무 코드도 등록이 되지 않은 경우 게시판 신규 등록은 불가능하게 만들었다.

그리고 status가 200이 아예 아닐 경우 status와 statusText가 보이도록 해놓았다.

 

 

 

 

2. (2) 코드 신규 등록 * 위치는 /src/views/components 이다.

 

// src/views/components/CreateCode.js

import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { codeActions } from '../../slices/codeSlice';

function CreateCode({ setShowCreateCode }) {
    const [ code, setCode ] = useState({});
    const dispatch = useDispatch();

    function onChangeCode(e) {
        setCode({
            ...code,
            [e.currentTarget.name]: e.currentTarget.value
        });
    }

    function onClickSubmitButton() {
        if (code.value !== "" && code.desc !== "")
        {
            dispatch(codeActions.postCode({ code, setShowCreateCode }));
        } else {
            alert("빠짐없이 입력해주세요.");
        }
    }

    return (
        <div>
            <div>
                <span>코드 설명: </span>
                <input name="desc" onChange={onChangeCode} value={code?.desc ?? ""} />
            </div>
            <div>
                <span>코드 설정값: </span>
                <input name="value" onChange={onChangeCode} value={code?.value ?? ""} />
            </div>
            <div>
                <button onClick={onClickSubmitButton}>등록</button>
            </div>
        </div>
    );
}

export default CreateCode;

 

코드 신규등록의 경우 가장 초기설정값을 등록하는 화면이므로 게시판 신규 등록 화면보다 간단하게 생겼다.

 

따로 조회해올 값도 없어 useEffect가 안 쓰였다.

 

useState로 코드 입력 값을 set해주는 방법은 CreateBoard와 거의 유사하므로 설명은 생략하겠다.

 

 

 

 

2. (3) 게시판 수정  * 위치는 /src/views/components 이다.

 

// src/views/components/UpdateBoardList.js

import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { boardActions } from '../../slices/boardSlice';
import { codeActions } from '../../slices/codeSlice';

function UpdateBoardList({ setShowUpdateBoardList }) {
    const { boardList, boardStatus, boardStatusText } = useSelector(
        (state) => ({
            boardList: state.boardReducer.boardList,
            boardStatus: state.boardReducer.status,
            boardStatusText: state.boardReducer.statusText
    }));
    const { codeList, codeStatus, codeStatusText } = useSelector(
        (state) => ({
            codeList: state.codeReducer.codeList,
            codeStatus: state.codeReducer.status,
            codeStatusText: state.codeReducer.statusText
    }));
    const [ updatedBoardList, setUpdatedBoardList ] = useState(boardList ?? []);
    const dispatch = useDispatch();

    function onChangeBoard(e) {
        const copiedBoardList = [ ...updatedBoardList ];
        copiedBoardList[e.target?.dataset?.index] = {
            ...copiedBoardList[e.target?.dataset?.index],
            [e.target?.name]: e.target?.value
        };
        setUpdatedBoardList(copiedBoardList);
    }

    function onClickSubmitButton(updatedBoard) {
        if (!updatedBoard?.name || !updatedBoard.code
            || updatedBoard?.name === "" || updatedBoard.code === "") {
            alert("빠짐없이 입력해주세요.");
        } else {
            dispatch(boardActions.putBoard(updatedBoard));
        }
    }

    function onClickDeleteButton(boardId) {
        if (!window.confirm("삭제하시겠습니까?")) return false;
        dispatch(boardActions.deleteBoard(boardId));
    }

    useEffect(() => {
        dispatch(boardActions.getBoardList());
        dispatch(codeActions.getCodeList());
    }, [dispatch]);

    useEffect(() => {
        setUpdatedBoardList(boardList);
    }, [boardList]);
    
    return (
        <div>
            {
                boardStatus === 200 ?
                updatedBoardList.length > 0 ?
                    updatedBoardList.map((updatedBoard, index) => 
                        <>
                            <div>
                                <span>게시판 이름: </span>
                                <input
                                    name="name"
                                    value={updatedBoard?.name ?? ""}
                                    data-index={index}
                                    onChange={onChangeBoard}
                                />
                            </div>
                            <div>
                                <span>게시판 코드값: </span>
                                {
                                    codeStatus === 200 ?
                                    codeList.length > 0 ?
                                        <select
                                            name="code"
                                            value={updatedBoard?.code ?? ""}
                                            data-index={index}
                                            onChange={onChangeBoard}
                                        >
                                            <option value={""}>선택</option>
                                            {
                                                codeList.length > 0 &&
                                                codeList.map((code) => (
                                                    <option value={code?.value}>{code?.desc ?? ""}</option>
                                                ))
                                            }
                                        </select>
                                    :
                                        <div>
                                            코드등록이 필요합니다.
                                        </div>
                                    :
                                    <>
                                        <div>
                                            <div>
                                                <span>{codeStatus}</span>
                                            </div>
                                            <div>
                                                <span>{codeStatusText}</span>
                                            </div>
                                        </div>
                                    </>
                                }
                            </div>
                            <div>
                                <button onClick={() => onClickSubmitButton(updatedBoard)}>저장</button>
                            </div>
                            <div>
                                <button onClick={() => onClickDeleteButton(updatedBoard?.id ?? 0)}>삭제</button>
                            </div>
                        </>
                    )
                : 
                    <div>
                        수정할 게시판이 없습니다.
                    </div>
                :
                    <div>
                        <div>
                            <span>{boardStatus}</span>
                        </div>
                        <div>
                            <span>{boardStatusText}</span>
                        </div>
                    </div>
            }
        </div>
    );
}

export default UpdateBoardList;

 

 

게시판 수정 컴포넌트 흐름에 따라 코드를 보면

 

const { boardList, boardStatus, boardStatusText } = useSelector(
    (state) => ({
        boardList: state.boardReducer.boardList,
        boardStatus: state.boardReducer.status,
        boardStatusText: state.boardReducer.statusText
}));
const { codeList, codeStatus, codeStatusText } = useSelector(
    (state) => ({
        codeList: state.codeReducer.codeList,
        codeStatus: state.codeReducer.status,
        codeStatusText: state.codeReducer.statusText
}));
const [ updatedBoardList, setUpdatedBoardList ] = useState(boardList ?? []);

useEffect(() => {
    dispatch(boardActions.getBoardList());
    dispatch(codeActions.getCodeList());
}, [dispatch]);

 

우선 첫번째 useEffect 에서 getBoardList, getCodeList 액션을 호출하여 게시판 수정 전 가장 최신상태값을 들고 온다.

그리고 가져온 최신 게시판과 코드 리스트들은 useSelector의 구독기능으로 state에도 업데이트 된다.

그리고 boardList의 값이 바뀔 때마다 

 

useEffect(() => {
    setUpdatedBoardList(boardList);
}, [boardList]);

 

화면에서 쓸 state인 updatedBoardList 에 boardList를 셋해준다

 

원래 useEffect 한 개로 쓰려 했는데 조회가 멈추질 않아서 결국 두 개로 나눴다.

 

 

들어온 boardList 값을 map을 이용하여 화면에 뿌려주었고

들어온 codeList도 게시판 안의 코드값 설정 부분의 option에 map으로 뿌려주었다.

 

updatedBoardList.map((updatedBoard, index) => 
    <>
        <div>
            <span>게시판 이름: </span>
            <input
                name="name"
                value={updatedBoard?.name ?? ""}
                data-index={index}
                onChange={onChangeBoard}
            />
        </div>
        <div>
            <span>게시판 코드값: </span>
            {
                codeStatus === 200 ?
                codeList.length > 0 ?
                    <select
                        name="code"
                        value={updatedBoard?.code ?? ""}
                        data-index={index}
                        onChange={onChangeBoard}
                    >
                        <option value={""}>선택</option>
                        {
                            codeList.length > 0 &&
                            codeList.map((code) => (
                                <option value={code?.value}>{code?.desc ?? ""}</option>
                            ))
                        }
                    </select>
                :
                    <div>
                        코드등록이 필요합니다.
                    </div>
                :
                <>
                    <div>
                        <div>
                            <span>{codeStatus}</span>
                        </div>
                        <div>
                            <span>{codeStatusText}</span>
                        </div>
                    </div>
                </>
            }
        </div>
        <div>
            <button onClick={() => onClickSubmitButton(updatedBoard)}>저장</button>
        </div>
        <div>
            <button onClick={() => onClickDeleteButton(updatedBoard?.id ?? 0)}>삭제</button>
        </div>
    </>
)

 

 

 

setUpdatedBoardList 를 이용한 onChange 이벤트 함수를 만들었다.

 

 

function onChangeBoard(e) {
    const copiedBoardList = [ ...updatedBoardList ];
    copiedBoardList[e.target?.dataset?.index] = {
        ...copiedBoardList[e.target?.dataset?.index],
        [e.target?.name]: e.target?.value
    };
    setUpdatedBoardList(copiedBoardList);
}

 

 

리스트 마다 value가 다르게 들어가야 하므로 index 를 이용하였다.

 

 

왜 onChange 를 리듀서 액션으로 안 만들었냐고 하시면,, 그냥이다.

만약 만들려면 게시판 조회해올 때마다 보여주는 게시판리스트와 수정용 게시판리스트 로 값을 같이 넣어줘야 한다.

그러기엔 boardSlice에 더 손대기 싫었다.

같은 boardList쓰면 되지 않냐는 물음에는 sidebar도 같은 state를 이용하므로 입력할 때마다 왼쪽 사이드바에 있는 게시판도 같이 값이 바뀔 것이고 Board 컴포넌트의 렌더링도 계속 같이 일어날 것이다.

 

더 깔끔한 코딩방식을 알게 되면 그 때 수정하거나

 

아니면 v3을 들고오거나 그럴 수도..

 

 

 

 

2. (4) 코드 수정  * 위치는 /src/views/components 이다.

 

// src/views/components/UpdateCodeList.js

import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { codeActions } from '../../slices/codeSlice';

function UpdateCodeList({ setShowUpdateCodeList }) {
    const { codeList, status, statusText } = useSelector((state) => state.codeReducer);
    const [ updatedCodeList, setUpdatedCodeList ] = useState(codeList ?? []);
    const dispatch = useDispatch();

    function onChangeCode(e) {
        const copiedCodeList = [ ...updatedCodeList ];
        copiedCodeList[e.target?.dataset?.index] = { ...copiedCodeList[e.target?.dataset?.index], [e.target?.name]: e.target?.value };
        setUpdatedCodeList(copiedCodeList);
    }

    function onClickSubmitButton(updatedCode) {
        if (!updatedCode?.value || !updatedCode.desc
            || updatedCode?.value === "" || updatedCode.desc === "") {
            alert("빠짐없이 입력해주세요.");
        } else {
            dispatch(codeActions.putCode(updatedCode));
        }
    }

    function onClickDeleteButton(codeId) {
        if (!window.confirm("삭제하시겠습니까?")) return false;
        dispatch(codeActions.deleteCode(codeId));
    }

    useEffect(() => {
        dispatch(codeActions.getCodeList());
    }, [dispatch]);

    useEffect(() => {
        setUpdatedCodeList(codeList);
    }, [codeList]);

    return (
        <div>
            {
                status === 200 ?
                updatedCodeList.length > 0 ?
                    updatedCodeList.map((updatedCode, index) => 
                        <>
                            <div>
                                <span>코드 설명: </span>
                                <input
                                    name="desc"
                                    value={updatedCode?.desc ?? ""}
                                    data-index={index}
                                    onChange={onChangeCode}
                                />
                            </div>
                            <div>
                                <span>게시판 코드값: </span>
                                <input
                                    name="value"
                                    value={updatedCode?.value ?? ""}
                                    data-index={index}
                                    onChange={onChangeCode}
                                />
                            </div>
                            <div>
                                <button onClick={() => onClickSubmitButton(updatedCode)}>저장</button>
                            </div>
                            <div>
                                <button onClick={() => onClickDeleteButton(updatedCode?.id ?? 0)}>삭제</button>
                            </div>
                        </>
                    )
                :
                    <div>
                        수정할 코드가 없습니다.
                    </div>
                :
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
            }
        </div>
    );
}

export default UpdateCodeList;

 

 

목록 조회부분과

 

useEffect(() => {
    dispatch(codeActions.getCodeList());
}, [dispatch]);

 

 

컴포넌트 state 값 설정부분

 

const { codeList, status, statusText } = useSelector((state) => state.codeReducer);
const [ updatedCodeList, setUpdatedCodeList ] = useState(codeList ?? []);

useEffect(() => {
    setUpdatedCodeList(codeList);
}, [codeList]);

 

 

그리고 onChange 부분은 게시판과 거의 유사하므로 설명은 생략하겠다.

 

function onChangeCode(e) {
    const copiedCodeList = [ ...updatedCodeList ];
    copiedCodeList[e.target?.dataset?.index] = {
        ...copiedCodeList[e.target?.dataset?.index],
        [e.target?.name]: e.target?.value
    };
    setUpdatedCodeList(copiedCodeList);
}

 

 

 

 

 

3. saga

 

// boardSaga.js

import { all, call, fork, getContext, put, take } from 'redux-saga/effects';
import { boardActions } from '../slices/boardSlice';
import axios from '../utils/axios';

// api 서버 연결 주소
function apiGetBoard(boardId) {
    return axios.get(`boards/${boardId}`);
}

function apiGetBoardList() {
    return axios.get(`boards`);
}

function apiPostBoard(requestBody) {
    return axios.post(`boards`, requestBody);
}

function apiPutBoard(requestBody) {
    return axios.put(`boards/${requestBody?.id}`, requestBody);
}

function apiDeleteBoard(boardId) {
    return axios.delete(`boards/${boardId}`);
}

// api 서버 연결 후 action 호출
function* asyncGetBoardList() {
    try {
        const response = yield call(apiGetBoardList);
        if (response?.status === 200) {
            yield put(boardActions.getBoardListSuccess(response));
        } else {
            
            yield put(boardActions.getBoardListFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(boardActions.getBoardListFail(e.response));
    }
}

function* asyncGetBoard(action) {
    try {
        const response = yield call(apiGetBoard, action.payload);
        if (response?.status === 200) {
            yield put(boardActions.getBoardSuccess());
        } else {
            yield put(boardActions.getBoardFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(boardActions.getBoardFail(e.response));
    }
}

function* asyncPostBoard(action) { // 추가
    try {
        const response = yield call(apiPostBoard, {
            ...action.payload.board,
            id: 0,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response?.status === 201) {
            yield put(boardActions.postBoardSuccess());
            alert("등록되었습니다!");
            yield call(action.payload?.setShowCreateBoard, false);
            yield put(boardActions.getBoardList());
        } else {
            yield put(boardActions.postBoardFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(boardActions.postBoardFail(e.response));
        yield alert(`등록 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* asyncPutBoard(action) { // 추가
    try {
        const response = yield call(apiPutBoard, { ...action.payload, updateDate: Date.now() });
        if (response?.status === 200) {
            yield put(boardActions.putBoardSuccess());
            alert("저장되었습니다!");
            yield put(boardActions.getBoardList());
        } else {
            yield put(boardActions.putBoardFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(boardActions.putBoardFail(e.response));
        yield alert(`저장 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* asyncDeleteBoard(action) { // 추가
    try {
        const response = yield call(apiDeleteBoard, action.payload);
        if (response?.status === 200) {
            yield put(boardActions.deleteBoardSuccess());
            alert("삭제되었습니다!");
            yield put(boardActions.getBoardList());
        } else {
            yield put(boardActions.deleteBoardFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(boardActions.deleteBoardFail(e.response));
        yield alert(`삭제 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}


// action 호출을 감시하는 watch 함수
function* watchGetBoardList() {
    while(true) {
        yield take(boardActions.getBoardList);
        yield call(asyncGetBoardList);
    }
}

function* watchGetBoard() {
    while(true) {
        const action = yield take(boardActions.getBoard);
        yield call(asyncGetBoard, action);
    }
}

function* watchPostBoard() { // 추가
    while(true) {
        const action = yield take(boardActions.postBoard);
        yield call(asyncPostBoard, action);
    }
}

function* watchPutBoard() { // 추가
    while (true) {
        const action = yield take(boardActions.putBoard);
        yield call(asyncPutBoard, action);
    }
}

function* watchDeleteBoard() { // 추가
    while (true) {
        const action = yield take(boardActions.deleteBoard);
        yield call(asyncDeleteBoard, action);
    }
}

export default function* boardSaga()
{
    yield all([fork(watchGetBoardList), fork(watchGetBoard), fork(watchPostBoard), fork(watchPutBoard), fork(watchDeleteBoard)]);
}

 

 

게시판 신규 등록의 경우 CreateBoard.js 에서 seShowCreateBoard (컴포넌트 보여줄지 말지 결정하는 set메서드)를 action.payload로 넘겨준 것을 호출하였다.

 

function* asyncPostBoard(action) {
    try {
        const response = yield call(apiPostBoard, {
            ...action.payload.board,
            id: 0,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response?.status === 201) {
            yield put(boardActions.postBoardSuccess());
            alert("등록되었습니다!");
            yield call(action.payload?.setShowCreateBoard, false);
            yield put(boardActions.getBoardList());
        } else {
            yield put(boardActions.postBoardFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(boardActions.postBoardFail(e.response));
        yield alert(`등록 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

 

그래서 등록 성공시 입력폼 접히는 효과가 난다. put과 delete 부분에는 안 넣었다. 그래서 저장이나 삭제가 완료되면 게시판 목록을 다시 조회해줄 뿐 입력폼은 그대로 펼쳐져 있다.

게시판 수정 입력폼을 열어놓은 채로 게시글을 등록하면 새로운 입력폼이 밑에 생기는 것이 보일 것이다.

UpdateBoardList 컴포넌트에서 boardList 가 업데이트 될 때마다 setUpdatedBoardList가 동작하므로 컴포넌트도 바로 렌더링 된다.

 

 

 

 

// codeSaga.js

import { all, call, fork, put, take } from 'redux-saga/effects';
import { codeActions } from '../slices/codeSlice';
import axios from '../utils/axios';

// api 서버 연결 주소
function apiGetCode(codeId) {
    return axios.get(`codes/${codeId}`);
}

function apiGetCodeList() {
    return axios.get(`codes`);
}

function apiPostCode(requestBody) {
    return axios.post(`codes`, requestBody);
}

function apiPutCode(requestBody) {
    return axios.put(`codes/${requestBody?.id}`, requestBody);
}

function apiDeleteCode(codeId) {
    return axios.delete(`codes/${codeId}`);
}

// api 서버 연결 후 action 호출
function* asyncGetCodeList() {
    try {
        const response = yield call(apiGetCodeList);
        if (response?.status === 200) {
            yield put(codeActions.getCodeListSuccess(response));
        } else {
            yield put(codeActions.getCodeListFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(codeActions.getCodeListFail(e.response));
    }
}

function* asyncGetCode(action) {
    try {
        const response = yield call(apiGetCode, action.payload);
        if (response?.status === 200) {
            yield put(codeActions.getCodeSuccess());
        } else {
            yield put(codeActions.getCodeFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(codeActions.getCodeFail(e.response));
    }
}

function* asyncPostCode(action) {
    try {
        const response = yield call(apiPostCode, {
            ...action.payload.code,
            id: 0,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response?.status === 201) {
            yield put(codeActions.postCodeSuccess());
            alert("등록되었습니다!");
            yield call(action.payload?.setShowCreateCode, false);
            yield put(codeActions.getCodeList());
        } else {
            yield put(codeActions.postCodeFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(codeActions.postCodeFail(e.response));
        yield alert(`등록 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* asyncPutCode(action) {
    try {
        const response = yield call(apiPutCode, { ...action.payload, updateDate: Date.now() });
        if (response?.status === 200) {
            yield put(codeActions.putCodeSuccess());
            alert("저장되었습니다!");
            yield put(codeActions.getCodeList());
        } else {
            yield put(codeActions.putCodeFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(codeActions.putCodeFail(e.response));
        yield alert(`저장 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* asyncDeleteCode(action) {
    try {
        const response = yield call(apiDeleteCode, action.payload);
        if (response?.status === 200) {
            yield put(codeActions.deleteCodeSuccess());
            alert("삭제되었습니다!");
            yield put(codeActions.getCodeList());
        } else {
            yield put(codeActions.deleteCodeFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(codeActions.deleteCodeFail(e.response));
        yield alert(`삭제 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

// action 호출을 감시하는 watch 함수
function* watchGetCodeList() {
    while(true) {
        yield take(codeActions.getCodeList);
        yield call(asyncGetCodeList);
    }
}

function* watchGetCode() {
    while(true) {
        const action = yield take(codeActions.getCode);
        yield call(asyncGetCode, action);
    }
}

function* watchPostCode() {
    while(true) {
        const action = yield take(codeActions.postCode);
        yield call(asyncPostCode, action);
    }
}

function* watchPutCode() {
    while (true) {
        const action = yield take(codeActions.putCode);
        yield call(asyncPutCode, action);
    }
}

function* watchDeleteCode() {
    while (true) {
        const action = yield take(codeActions.deleteCode);
        yield call(asyncDeleteCode, action);
    }
}

export default function* codeSaga()
{
    yield all([fork(watchGetCodeList), fork(watchGetCode), fork(watchPostCode), fork(watchPutCode), fork(watchDeleteCode)]);
}

 

 

codeSaga 는 boardSaga와 거의 똑같다. 이름만 바뀐 것이라고 생각될 정도이다.

그래서 설명도 복붙하여 명사만 바꾸겠다.

 

코드신규 등록 도 CreateCode.js 에서 seShowCreateCode (컴포넌트 보여줄지 말지 결정하는 set메서드)를 action.payload로 넘겨준 것을 호출하였다.

 

function* asyncPostCode(action) {
    try {
        const response = yield call(apiPostCode, {
            ...action.payload.code,
            id: 0,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response?.status === 201) {
            yield put(codeActions.postCodeSuccess());
            alert("등록되었습니다!");
            yield call(action.payload?.setShowCreateCode, false);
            yield put(codeActions.getCodeList());
        } else {
            yield put(codeActions.postCodeFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(codeActions.postCodeFail(e.response));
        yield alert(`등록 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

 

그래서 등록 성공시 입력폼 접히는 효과가 난다. put과 delete 부분에는 안 넣었다. 그래서 저장이나 삭제가 완료되면 게시판 목록을 다시 조회해줄 뿐 입력폼은 그대로 펼쳐져 있다.

코드 수정 입력폼을 열어놓은 채로 코드를 등록하면 새로운 입력폼이 밑에 생기는 것이 보일 것이다.

UpdateCodeList 컴포넌트에서 codeList 가 업데이트 될 때마다 setUpdatedCodeList가 동작하므로 컴포넌트도 바로 렌더링 된다.

 

 

 

설정 화면의 컴포넌트를 다 펼쳐(true) 놓으면 이렇게 된다.

 

 

 

 

 

목차 돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

목차 돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

articleSlice.js 에 삭제 액션을 만들어준다.

 

 

// articleSlice.js

deleteArticle: (state, action) => {},
deleteArticleSuccess: (state, action) => {
    state.article = initialState.article;
    state.status = action.payload?.status;
    state.statusText = action.payload?.statusText ?? "";
},
deleteArticleFail: (state, action) => {
    state.status = action.payload?.status ?? 500;
    state.statusText = action.payload?.statusText ?? "Network Error";
},

 

articleSaga.js에 메 api함수, async함수를 만들어준다.

 

// articleSaga.js

function apiDeleteArticle(articleId) {
    return axios.delete(`articles/${articleId}`);
}

function* asyncDeleteArticle() {
    try {
        const article = yield select((state) => state.articleReducer.article);
        const response = yield call(apiDeleteArticle, article?.id ?? 0);
        if (response?.status === 200) {
            yield put(articleActions.deleteArticleSuccess());
            alert("삭제되었습니다!");
            history.push(`/board/${article?.boardId ?? 0}`);
        } else {
            yield put(articleActions.deleteArticleFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.deleteArticleFail(e?.response));
        yield alert(`삭제실패 \n Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* watchDeleteArticle() {
    while(true) {
        yield take(articleActions.deleteArticle);
        yield call(asyncDeleteArticle);
    }
}

export default function* articleSaga()
{
    yield all([fork(watchGetArticleList), fork(watchGetArticle),
        fork(watchUpdateArticleViews), fork(watchPostArticle),
        fork(watchSetArticle), fork(watchPutArticle), fork(watchDeleteArticle)]);
}

 

삭제 버튼을 Article.js 에 만들어준다.

 

// Article.js

function onClickDeleteButton() {
    dispatch(articleActions.deleteArticle());
}

<div>
    <button onClick={onClickDeleteButton}>삭제</button>
</div>

 

끝이다!

 

 

이제 손댈 데가 없는 Article.js, articleSlice.js, articleSaga.js 모습을 공개한다.

 

// Article.js

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import Comment from './Comments';
import { articleActions } from '../slices/articleSlice';

function Article() {
    const params = useParams();
    const { article, status, statusText } = useSelector((state) => state.articleReducer);
    const boardList = useSelector((state) => state.boardReducer.boardList);
    const dispatch = useDispatch();
    const history = useHistory();

    function onClickUpdateButton() {
        history.push(`/update/${params?.articleId ?? 0}`);
    }

    function onClickDeleteButton() {
        if (!window.confirm("삭제하시겠습니까?")) return false;
        dispatch(articleActions.deleteArticle());
    }

    useEffect(() => {
        dispatch(articleActions.getArticle(params?.articleId ?? 0));
    }, [dispatch, params?.articleId]);
    return (
        <>
            {
                status === 200 ?
                    <>
                        <div>
                            <button onClick={onClickUpdateButton}>수정</button>
                        </div>
                        <div>
                            <button onClick={onClickDeleteButton}>삭제</button>
                        </div>
                        <div>
                            <span>게시판: </span>
                            <span>
                                {
                                    boardList.length > 0 &&
                                    boardList.find((board) => board.id === parseInt(article?.boardId))?.name
                                }
                            </span>
                        </div>
                        <div>
                            <div><span>제목: </span><span>{article?.title ?? ""}</span></div>
                            <div><span>조회수: </span><span>{article?.views ?? ""}</span></div>
                            <div><span>작성일시: </span><span>{(article.insertDate) ? new Date(article?.insertDate).toLocaleString() : ""}</span></div>
                            <div><span>내용: </span><span>{article?.content ?? ""}</span></div>
                        </div>
                        <div>
                            <Comment articleId={params?.articleId ?? 0} />
                        </div>
                    </>
                :
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
            }
        </>
    );
}

export default Article;

 

// articleSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "article";

const initialState = {
    article: {},
    articleList: [],
    status: 0,
    statusText: "Loading",
};

const reducers = {
    getArticleList: (state, action) => {},
    getArticleListSuccess: (state, action) => {
        state.articleList = action.payload?.data ?? [];
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    getArticleListFail: (state, action) => {
        state.articleList = initialState.articleList;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    getArticle: (state, action) => {},
    getArticleSuccess: (state, action) => {},
    getArticleFail: (state, action) => {
        state.article = initialState.article;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },
    
    updateArticleViews: (state, action) => {},
    updateArticleViewsSuccess: (state, action) => {
        state.article = action.payload?.data ?? {};
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    updateArticleViewsFail: (state, action) => {
        state.article = initialState.article;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    postArticle: (state, action) => {},
    postArticleSuccess: (state, action) => {},
    postArticleFail: (state, action) => {
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    setArticle: (state, action) => {},

    putArticle: (state, action) => {},
    putArticleSuccess: (state, action) => {},
    putArticleFail: (state, action) => {
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    deleteArticle: (state, action) => {},
    deleteArticleSuccess: (state, action) => {
        state.article = initialState.article;
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    deleteArticleFail: (state, action) => {
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },
};

const articleSlice = createSlice({
    name,
    initialState,
    reducers,
});

export const articleReducer = articleSlice.reducer;
export const articleActions = articleSlice.actions;

 

 

// articleSaga.js

import { all, call, retry, fork, put, take, select, getContext } from 'redux-saga/effects';
import { articleActions } from '../slices/articleSlice';
import axios from '../utils/axios';
import history from '../utils/history';
import qs from "query-string";

const SECOND = 1000;

// api 서버 연결 주소
function apiGetArticle(articleId) {
    return axios.get(`articles/${articleId}`);
}

function apiGetArticleList(requestParams) {
    return axios.get(`articles?${qs.stringify(requestParams)}`);
}

function apiPutArticle(requestBody) {
    return axios.put(`articles/${requestBody?.id}`, requestBody);
}

function apiPostArticle(requestBody) {
    return axios.post(`articles/`, requestBody);
}

function apiDeleteArticle(articleId) {
    return axios.delete(`articles/${articleId}`);
}

// api 서버 연결 후 action 호출
function* asyncGetArticleList(action) {
    try {
        // const response = yield call(apiGetArticleList, { boardId: action.payload });
        const response = yield retry(3, 10 * SECOND, apiGetArticleList, { boardId: action.payload });
        if (response?.status === 200) {
            yield put(articleActions.getArticleListSuccess(response));
        } else {
            yield put(articleActions.getArticleListFail(response));
        }
    } catch(e) {
        yield put(articleActions.getArticleListFail(e.response));
    }
}

function* asyncGetArticle(action) {
    try {
        const response = yield call(apiGetArticle, action.payload);
        if (response?.status === 200) {
            yield put(articleActions.getArticleSuccess()); // 조회 성공확인만 판단하는 용도로 남김
            yield put(articleActions.updateArticleViews(response.data));
        } else {
            yield put(articleActions.getArticleFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.getArticleFail(e.response));
    }
}

function* asyncUpdateArticleViews(action) {
    try {
        const response = yield call(apiPutArticle, {
            ...action.payload,
            views: parseInt(action.payload?.views ?? 0) + 1,
            updateDate: Date.now()
        });
        if (response?.status === 200) {
            yield put(articleActions.updateArticleViewsSuccess(response));
        } else {
            yield put(articleActions.updateArticleViewsFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.updateArticleViewsFail(e?.response));
    }
}

function* asyncPostArticle(action) {
    try {
        // const history = yield getContext("history"); // react-router-dom 의 BrowserRouter 에서는 안 됨
        const response = yield call(apiPostArticle, {
            ...action.payload,
            id: 0,
            views: 0,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response?.status === 201) {
            yield put(articleActions.postArticleSuccess());
            history.push(`/article/${response?.data?.id ?? 0}`);
        } else {
            yield put(articleActions.postArticleFail(response));
            yield alert(`등록실패 \n Error: ${response.status}, ${response.statusText}`);
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.postArticleFail(e?.response));
        yield alert(`등록실패 \n Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* asyncSetArticle(action) {
    try {

        const response = yield call(apiGetArticle, action.payload?.articleId);
        if (response?.status === 200) {
            yield call(action.payload?.setArticle, response?.data ?? {});
        } else {
            yield alert(`불러오기 실패 Error: ${response.status}, ${response.statusText}`);
            history.goBack();
        }
    } catch(e) {
        console.error(e);
        yield alert(`불러오기 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
        history.goBack();
    }
}

function* asyncPutArticle(action) {
    try {
        const response = yield call(apiPutArticle, {
            ...action.payload,
            updateData: Date.now()
        });
        if (response?.status === 200) {
            yield put(articleActions.putArticleSuccess());
            history.push(`/article/${response?.data?.id ?? 0}`);
        } else {
            yield put(articleActions.putArticleFail(response));
            yield alert(`수정실패 \n Error: ${response.status}, ${response.statusText}`);
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.putArticleFail(e?.response));
        yield alert(`수정실패 \n Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* asyncDeleteArticle() {
    try {
        const article = yield select((state) => state.articleReducer.article);
        const response = yield call(apiDeleteArticle, article?.id ?? 0);
        if (response?.status === 200) {
            yield put(articleActions.deleteArticleSuccess());
            alert("삭제되었습니다!");
            history.push(`/board/${article?.boardId ?? 0}`);
        } else {
            yield put(articleActions.deleteArticleFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.deleteArticleFail(e?.response));
        yield alert(`삭제실패 \n Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

// action 호출을 감시하는 watch 함수
function* watchGetArticleList() {
    while(true) {
        const action = yield take(articleActions.getArticleList);
        yield call(asyncGetArticleList, action);
    }
}

function* watchGetArticle() {
    while(true) {
        const action = yield take(articleActions.getArticle);
        yield call(asyncGetArticle, action);
    } 
}

function* watchUpdateArticleViews() {
    while(true) {
        const action = yield take(articleActions.updateArticleViews);
        yield call(asyncUpdateArticleViews, action);
    }
}

function* watchPostArticle() {
    while(true) {
        const action = yield take(articleActions.postArticle);
        yield call(asyncPostArticle, action);
    }
}

function* watchSetArticle() {
    while(true) {
        const action = yield take(articleActions.setArticle);
        yield call(asyncSetArticle, action);
    }
}

function* watchPutArticle() {
    while(true) {
        const action = yield take(articleActions.putArticle);
        yield call(asyncPutArticle, action);
    }
}

function* watchDeleteArticle() {
    while(true) {
        yield take(articleActions.deleteArticle);
        yield call(asyncDeleteArticle);
    }
}

export default function* articleSaga()
{
    yield all([fork(watchGetArticleList), fork(watchGetArticle),
        fork(watchUpdateArticleViews), fork(watchPostArticle),
        fork(watchSetArticle), fork(watchPutArticle), fork(watchDeleteArticle)]);
}

 

 

 

 

글 등록, 수정, 삭제가 이제까지 제대로 안 됐다면 소스를 다시 확인해 보자!

 

 

목차 돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

목차 돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

수정으로 들어가기 전에 Article.js 에 수정 버튼을 만들어준다.

 

// Article.js

const history = useHistory();

function onClickUpdateButton() {
	history.push(`/update/${params?.articleId ?? 0}`);
}

<div>
	<button onClick={onClickUpdateButton}>수정</button>
</div>

 

나는 게시판 명 위에 만들었다.

 

수정의 경우 "/update/:articleId" 로 이동하므로

 

파라미터 articleId를 이용하여 수정할 게시글 내용을 불러온다.

 

 

// articleSlice.js


setArticle: (state, action) => {},

 

위와 같은 액션을 만들고

 

// articleSaga.js

function* asyncSetArticle(action) {
    try {

        const response = yield call(apiGetArticle, action.payload?.articleId);
        if (response?.status === 200) {
            yield call(action.payload?.setArticle, response?.data ?? {});
        } else {
            yield alert(`불러오기 실패 Error: ${response.status}, ${response.statusText}`);
            history.goBack();
        }
    } catch(e) {
        console.error(e);
        yield alert(`불러오기 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
        history.goBack();
    }
}

function* watchSetArticle() {
    while(true) {
        const action = yield take(articleActions.setArticle);
        yield call(asyncSetArticle, action);
    }
}

export default function* articleSaga()
{
    yield all([fork(watchGetArticleList), fork(watchGetArticle),
        fork(watchUpdateArticleViews), fork(watchPostArticle),
        fork(watchSetArticle)]);
}

 

saga는 이렇게 작성하였다.

 

asyncSet~ 액션은 게시글 한 개 내용을 가져올 때 썼던 api함수를 재활용 했다. getArticle 액션을 다시 쓸 수 있음 좋았겠지만 조회수 문제 때문에 안된다. (게시글 조회수 update 편에서 아쉬워했던 이유가 이것 때문..)

 

게시판 내용 불러오기가 실패한 경우 알림창만 띄우고 뒤로가기 할 것이므로 Success, Fail 추가 액션은 만들지 않았다.

alert 가 떠있을 때 콘솔창을 열면 에러가 보일 순 있겠다.

 

저기 action.payload?.setArticle 은 액션을 통해 넘겨준 함수를 꺼내는 부분인데 call을 이용해서 호출한다.

 

if (response?.status === 200) {
	yield call(action.payload?.setArticle, response?.data ?? {});
}

 

넘겨주는 부분은 /views/Post.js 에서 여기다

 

// Post.js

const params = useParams();

useEffect(() => {
    if (params?.articleId) {
        dispatch(articleActions.setArticle({ articleId: params?.articleId, setArticle }));
    } else {
        setArticle({}); // 새글 쓰기 염두
    }
}, [dispatch, params?.articleId]);

 

useState로 만든  setArticle 함수를 action.payload로 바로 넘겨주는 것이다

 

 

컴파일이 된 뒤 수정 버튼을 클릭하여 렌더링된 update 화면은 다음과 같이 나타나야 한다.

 

 

 

 

 

이제 본격적으로 수정해보자

 

이젠 거의 패턴화된 순서

 

articleSlice 에 액션 생성

 

// articleSlice.js

	
putArticle: (state, action) => {},
putArticleSuccess: (state, action) => {},
putArticleFail: (state, action) => {
    state.status = action.payload?.status ?? 500;
    state.statusText = action.payload?.statusText ?? "Network Error";
},

 

articleSaga에 async 함수 생성

api는 조회수 put했던 api함수를 재활용한다.

 

// articleSaga.js

function* asyncPutArticle(action) {
    try {
        const response = yield call(apiPutArticle, {
            ...action.payload,
            updateData: Date.now()
        });
        if (response?.status === 200) {
            yield put(articleActions.putArticleSuccess());
            history.push(`/article/${response?.data?.id ?? 0}`);
        } else {
            yield put(articleActions.putArticleFail(response));
            yield alert(`수정실패 \n Error: ${response.status}, ${response.statusText}`);
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.putArticleFail(e?.response));
        yield alert(`수정실패 \n Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* watchPutArticle() {
    while(true) {
        const action = yield take(articleActions.putArticle);
        yield call(asyncPutArticle, action);
    }
}

export default function* articleSaga()
{
    yield all([fork(watchGetArticleList), fork(watchGetArticle),
        fork(watchUpdateArticleViews), fork(watchPostArticle),
        fork(watchSetArticle), fork(watchPutArticle)]);
}

 

 

/views/Post.js 는 insert 와  update 때 같이 사용하므로 등록 버튼에서 수정과 등록을 가려줄 if문을 추가해줘야 한다.

 

// Post.js

function onClickSubmitButton() {
    if (article?.boardId > 0 && article?.title)
    {
        if (article?.id > 0)
        {
            dispatch(articleActions.putArticle(article));
        } else {
            dispatch(articleActions.postArticle(article));
        }
    } else {
        alert("게시판과 제목은 필수값입니다.");
    }
}

 

수정과 신규등록 차이점은 게시글 id 존재 여부이므로 일단 저렇게 if문 조건을 달아본다.

 

 

 

 

수정이 잘 일어나면 된 거다

 

 

그리고 다음 과정(설정 만들기-게시판, 코드 등록,수정,삭제)을 위해 버튼 하나를 추가해두겠다.

버튼 추가하면서  리팩토링 된 부분이 있다.

 

// Post.js

import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SELECT } from '../utils/events';
import { articleActions } from '../slices/articleSlice';
import { useHistory, useParams } from 'react-router';

function Post() {
    const { boardList, boardStatus, boardStatusText } = useSelector(
        (state) => ({
            boardList: state.boardReducer.boardList,
            boardStatus: state.boardReducer.status,
            boardStatusText: state.boardReducer.statusText
    }));
    const { articleStatus, articleStatusText } = useSelector(
        (state) => ({
            articleStatus: state.articleReducer.status,
            articleStatusText: state.articleReducer.statusText
    }));
    const [ article, setArticle ] = useState({});
    const dispatch = useDispatch();
    const history = useHistory();
    const params = useParams();

    function onChangeArticle(e) {
        setArticle({
            ...article,
            [e.currentTarget.name]: e.currentTarget.value
        });
    }

    function onClickSubmitButton() {
        if (article?.boardId > 0 && article?.title)
        {
            if (article?.id > 0)
            {
                dispatch(articleActions.putArticle(article));
            } else {
                dispatch(articleActions.postArticle(article));
            }
        } else {
            alert("게시판과 제목은 필수값입니다.");
        }
    }

    function onClickMoveToControlButton() {
        history.push("/control");
    }

    useEffect(() => {
        if (params?.articleId) {
            dispatch(articleActions.setArticle({ articleId: params?.articleId, setArticle }));
        } else {
            setArticle({});
        }
    }, [dispatch, params?.articleId]);

    
    return (
            <div>
                { boardStatus === 200 && boardList.length > 0 ?
                    (
                        <>
                            <div>
                                <span>게시판: </span>
                                <select
                                    name="boardId"
                                    onChange={onChangeArticle}
                                    value={article?.boardId ?? 0}
                                >
                                    <option value={SELECT.id} key={SELECT.id}>{SELECT.name}</option>
                                    { 
                                        boardList.map((board, index) => (
                                            <option value={board?.id} key={board?.id}>{board?.name ?? ""}</option>
                                        ))
                                    }
                                </select>
                            </div>
                            <div>
                                <span>제목: </span>
                                <input
                                    name="title"
                                    onChange={onChangeArticle}
                                    value={article?.title ?? ""}
                                />
                            </div>
                            <div>
                                <span>내용: </span>
                                <textarea
                                    name="content"
                                    onChange={onChangeArticle}
                                    value={article?.content ?? ""}
                                />
                            </div>
                            <button onClick={onClickSubmitButton}>등록</button>
                        </>
                    ) : boardStatus === 200 && boardList.length === 0 ?
                    (
                        <div>
                            <div>
                                게시판 등록이 필요합니다.
                            </div>
                            <div>
                                <button onClick={onClickMoveToControlButton}>설정 이동</button>
                            </div>
                        </div>
                    ) : (
                        <div>
                            <div>
                                <span>{boardStatus}</span>
                            </div>
                            <div>
                                <span>{boardStatusText}</span>
                            </div>
                        </div>
                    )
                }
                { articleStatus !== 200 && articleStatus !== 0 && (
                    <div>
                        <div>
                            <span>{articleStatus}</span>
                        </div>
                        <div>
                            <span>{articleStatusText}</span>
                        </div>
                    </div>
                )}
            </div>
    );
}

export default Post;

 

Post.js 의 status는 boardReducer에서 가져오는 부분이기 때문에 articleReducer의 status를 사용하려면 변수명이 겹쳐 사용할 수 없었다.

그래서 useSelector 안 에서 이름을 바꿔서 값을 들고왔다.

 

    const { boardList, boardStatus, boardStatusText } = useSelector(
        (state) => ({
            boardList: state.boardReducer.boardList,
            boardStatus: state.boardReducer.status,
            boardStatusText: state.boardReducer.statusText
    }));
    const { articleStatus, articleStatusText } = useSelector(
        (state) => ({
            articleStatus: state.articleReducer.status,
            articleStatusText: state.articleReducer.statusText
    }));

 

 

맨 아래 붙어있는 articleStatus 는 등록이나 수정과정에서 실패할 경우 추가로 뜰 공간이다.

 

{ articleStatus !== 200 && articleStatus !== 0 && (
    <div>
        <div>
            <span>{articleStatus}</span>
        </div>
        <div>
            <span>{articleStatusText}</span>
        </div>
    </div>
)}

 

 

게시판이 모두 미리 등록이 되어있어야 게시글 등록도 가능하므로 설정 화면을 만들게 되었다.

그래서 게시글 등록시 사용할 게시판이 없다면 설정화면으로 바로 이동할 수 있도록 (사용자 편의를 위하여) 버튼을 추가한 것이다.

 

function onClickMoveToControlButton() {
    history.push("/control");
}

<button onClick={onClickMoveToControlButton}>설정 이동</button>

 

 

 

------ 4/15 추가

 

 

/src/views/Post.js 에 추가사항이 생겼다.

 

게시판명 combo box 부분은 나중에 설정에서 새로 등록된 게시판 혹은 이름이 수정된 게시판, 삭제된 게시판을 반영시켜 나타나야 하기 때문에 게시글 등록/수정하는 Post 컴포넌트가 렌더링 될 때마다 게시판 목록을 조회해줘야 한다.

 

따라서 useEffect 부분에 액션 dispatch를 추가하겠다.

boardAction을 사용하므로 import 도 해줘야 한다.

 

// Post.js

import { boardActions } from '../slices/boardSlice';

useEffect(() => {
    dispatch(boardActions.getBoardList());
    if (params?.articleId) {
        dispatch(articleActions.setArticle({ articleId: params?.articleId, setArticle }));
    } else {
        setArticle({});
    }
}, [dispatch, params?.articleId]);

 

 

수정이 반영된 풀 코드는 다음과 같다.

 

 

// Post.js

import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SELECT } from '../utils/events';
import { articleActions } from '../slices/articleSlice';
import { boardActions } from '../slices/boardSlice';
import { useHistory, useParams } from 'react-router';

function Post() {
    const { boardList, boardStatus, boardStatusText } = useSelector(
        (state) => ({
            boardList: state.boardReducer.boardList,
            boardStatus: state.boardReducer.status,
            boardStatusText: state.boardReducer.statusText
    }));
    const { articleStatus, articleStatusText } = useSelector(
        (state) => ({
            articleStatus: state.articleReducer.status,
            articleStatusText: state.articleReducer.statusText
    }));
    const [ article, setArticle ] = useState({});
    const dispatch = useDispatch();
    const history = useHistory();
    const params = useParams();

    function onChangeArticle(e) {
        setArticle({
            ...article,
            [e.currentTarget.name]: e.currentTarget.value
        });
    }

    function onClickSubmitButton() {
        if (article?.boardId > 0 && article?.title)
        {
            if (article?.id > 0)
            {
                dispatch(articleActions.putArticle(article));
            } else {
                dispatch(articleActions.postArticle(article));
            }
        } else {
            alert("게시판과 제목은 필수값입니다.");
        }
    }

    function onClickMoveToControlButton() {
        history.push("/control");
    }

    useEffect(() => {
        dispatch(boardActions.getBoardList());
        if (params?.articleId) {
            dispatch(articleActions.setArticle({ articleId: params?.articleId, setArticle }));
        } else {
            setArticle({});
        }
    }, [dispatch, params?.articleId]);

    
    return (
            <div>
                { boardStatus === 200 && boardList.length > 0 ?
                    (
                        <>
                            <div>
                                <span>게시판: </span>
                                <select
                                    name="boardId"
                                    onChange={onChangeArticle}
                                    value={article?.boardId ?? 0}
                                >
                                    <option value={SELECT.id} key={SELECT.id}>{SELECT.name}</option>
                                    { 
                                        boardList.map((board, index) => (
                                            <option value={board?.id} key={board?.id}>{board?.name ?? ""}</option>
                                        ))
                                    }
                                </select>
                            </div>
                            <div>
                                <span>제목: </span>
                                <input
                                    name="title"
                                    onChange={onChangeArticle}
                                    value={article?.title ?? ""}
                                />
                            </div>
                            <div>
                                <span>내용: </span>
                                <textarea
                                    name="content"
                                    onChange={onChangeArticle}
                                    value={article?.content ?? ""}
                                />
                            </div>
                            <button onClick={onClickSubmitButton}>등록</button>
                        </>
                    ) : boardStatus === 200 && boardList.length === 0 ?
                    (
                        <div>
                            <div>
                                게시판 등록이 필요합니다.
                            </div>
                            <div>
                                <button onClick={onClickMoveToControlButton}>설정 이동</button>
                            </div>
                        </div>
                    ) : (
                        <div>
                            <div>
                                <span>{boardStatus}</span>
                            </div>
                            <div>
                                <span>{boardStatusText}</span>
                            </div>
                        </div>
                    )
                }
                { articleStatus !== 200 && articleStatus !== 0 && (
                    <div>
                        <div>
                            <span>{articleStatus}</span>
                        </div>
                        <div>
                            <span>{articleStatusText}</span>
                        </div>
                    </div>
                )}
            </div>
    );
}

export default Post;

 

 

 

 

 

 

목차 돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

이제까지 거의 GET방식을 이용한 조회를 해봤다 (조회수 update, 댓글 등록, 댓글삭제 제외)

 

 

이번엔 새 글 쓰기를 해보겠다.

 

새 글 쓰기는 지난번 Route.js 에서

경로가 "/insert" 일 때 컴포넌트는 Post 로 렌더링되도록 설정해놓았다.

 

 

 

새글 쓰기는 어디에서나 가능하도록 하려 한다. 그래서 Views.js 에 id=header 인 태그 부분에 이벤트함수를 다음과 같이 집어넣었다.

 

 

// Views.js

import React from 'react';
import Board from "./views/Board";
import Routes from "./routes/Routes";
import "./Views.css";
import { useHistory } from 'react-router';

function Views() {
    const history = useHistory();

    function onClickNewPostButton() {
        history.push("/insert");
    }

    function onClickControlButton() {
        history.push("/control");
    }

    return (
        <div >
            <div id="header" className="header">
                <div >
                    <h3>Board CRUD</h3>
                </div>
                <div>
                    <div>
                        <button onClick={onClickNewPostButton}>새글</button>
                    </div>
                </div>
            </div>
            <div id="sidebar" className="sidebar">
                <Board />
            </div>
            <div id="content" className="content">
                <Routes />
            </div>
        </div>
    );
}

export default Views;

 

 

 

버튼을 누르면 이렇게 될 것이다.

 

 

이제 Post를 좀 그럴듯한 글쓰기 화면으로 바꾸겠다

 

 

// Posts.js

import React from 'react';

function Post() {
    return (
        <div>
            <div>
                <span>게시판: </span>
                <select>
                    <option>선택</option>
                    <option></option>
                    <option></option>
                </select>
            </div>
            <div>
                <span>제목: </span><input></input>
            </div>
            <div>
            <span>내용: </span><textarea></textarea>
            </div>
        </div>
    );
}

export default Post;

 

 

 

 

(이번에는 게시판 고르는 combo box도 추가되었다.)

 

 

게시글 쓰는 부분도 useState로만 진행하려고 한다.

 

redux 의 useSelector 를 쓰는 부분은 게시판 combo box에만 사용할 것 같다.

 

나는 게시판 목록을 서버에서 다시 불러와서 쓸 게 아니라 state에 있는 boardList 다시 불러와서 쓸 계획이었는데

 

화면 구조 상 게시판 정보가 없어도 Header 부분 새글 버튼만 눌러도 Post로 이동이 가능하므로

Post 에 게시판이 없을 때 보여 줄 JSX를 만들어야 겠다.

 

 

나는 이렇게 바꿔주었다.

 

// Post.js

import React from 'react';
import { useSelector } from 'react-redux';

function Post() {
    const { boardList, status, statusText } = useSelector((state) => state.boardReducer);
    
    return (
            <div>
                { status === 200 && boardList.length > 0 ?
                    (
                        <>
                            <div>
                                <span>게시판: </span>
                                <select>
                                    <option>선택</option>
                                    <option></option>
                                    <option></option>
                                </select>
                            </div>
                            <div>
                                <span>제목: </span><input></input>
                            </div>
                            <div>
                                <span>내용: </span><textarea></textarea>
                            </div>
                        </>
                    ) : status === 200 && boardList.length === 0 ?
                    (
                        <div>
                            게시판 등록이 필요합니다.
                        </div>
                    ) : (
                        <div>
                            <div>
                                <span>{status}</span>
                            </div>
                            <div>
                                <span>{statusText}</span>
                            </div>
                        </div>
                    )
                }
            </div>
    );
}

export default Post;

 

 

이제 combo box 를 바꿔줄 차례인데 나는 combo box 와 관련해서 Constants를 아예 utils 에 공통으로 쓸 수 있도록 만들었다.

 

/utils/events.js

export const SELECT = { id: 0, name: "선택" };
export const WHOLE = { id: 0, name: "전체" };

 

(소스를 다시 정리해보니 event.js 는 결국 안 썼다)

 

// Post.js

import React from 'react';
import { useSelector } from 'react-redux';
import { SELECT } from '../utils/events';

function Post() {
    const { boardList, status, statusText } = useSelector((state) => state.boardReducer);
    
    return (
            <div>
                { status === 200 && boardList.length > 0 ?
                    (
                        <>
                            <div>
                                <span>게시판: </span>
                                <select name="boardId">
                                    <option value={SELECT.id}>{SELECT.name}</option>
                                    { 
                                        boardList.map((board) => (
                                            <option value={board?.id}>{board?.name ?? ""}</option>
                                        ))
                                    }
                                </select>
                            </div>
                            <div>
                                <span>제목: </span><input></input>
                            </div>
                            <div>
                                <span>내용: </span><textarea></textarea>
                            </div>
                        </>
                    ) : status === 200 && boardList.length === 0 ?
                    (
                        <div>
                            게시판 등록이 필요합니다.
                        </div>
                    ) : (
                        <div>
                            <div>
                                <span>{status}</span>
                            </div>
                            <div>
                                <span>{statusText}</span>
                            </div>
                        </div>
                    )
                }
            </div>
    );
}

export default Post;

 

 

게시판 combo box에 값이 들어가있는지 확인해보자

 

 

useState로 object 값 넣을 수 있는지 해봤는데 되길래 소스를 올린다.

 

// Post.js

import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { SELECT } from '../utils/events';

function Post() {
    const { boardList, status, statusText } = useSelector((state) => state.boardReducer);
    const [ article, setArticle ] = useState({});

    function onChangeArticle(e) {
        setArticle({
            ...article,
            [e.currentTarget.name]: e.currentTarget.value
        });
    }
    
    return (
            <div>
                { status === 200 && boardList.length > 0 ?
                    (
                        <>
                            <div>
                                <span>게시판: </span>
                                <select
                                    name="boardId"
                                    onChange={onChangeArticle}
                                    value={article?.boardId ?? 0}
                                >
                                    <option value={SELECT.id} key={SELECT.id}>{SELECT.name}</option>
                                    { 
                                        boardList.map((board, index) => (
                                            <option value={board?.id} key={board?.id}>{board?.name ?? ""}</option>
                                        ))
                                    }
                                </select>
                            </div>
                            <div>
                                <span>제목: </span>
                                <input
                                    name="title"
                                    onChange={onChangeArticle}
                                    value={article?.title ?? ""}
                                />
                            </div>
                            <div>
                                <span>내용: </span>
                                <textarea
                                    name="content"
                                    onChange={onChangeArticle}
                                    value={article?.content ?? ""}
                                />
                            </div>
                        </>
                    ) : status === 200 && boardList.length === 0 ?
                    (
                        <div>
                            게시판 등록이 필요합니다.
                        </div>
                    ) : (
                        <div>
                            <div>
                                <span>{status}</span>
                            </div>
                            <div>
                                <span>{statusText}</span>
                            </div>
                        </div>
                    )
                }
            </div>
    );
}

export default Post;

 

 

input에 값 집어넣기가 되니 이제 서버에 보내보자

 

게시판 태그들을 감쌌던 <></> 안에 등록 버튼을 만들어준다. 

 

 

// Post.js

<>
        <div>
            <span>게시판: </span>
            <select
                name="boardId"
                onChange={onChangeArticle}
                value={article?.boardId ?? 0}
            >
                <option value={SELECT.id} key={SELECT.id}>{SELECT.name}</option>
                { 
                    boardList.map((board, index) => (
                        <option value={board?.id} key={board?.id}>{board?.name ?? ""}</option>
                    ))
                }
            </select>
        </div>
        <div>
            <span>제목: </span>
            <input
                name="title"
                onChange={onChangeArticle}
                value={article?.title ?? ""}
            />
        </div>
        <div>
            <span>내용: </span>
            <textarea
                name="content"
                onChange={onChangeArticle}
                value={article?.content ?? ""}
            />
        </div>
        <button>등록</button>
</>

 

 

articleSlice 와 articleSaga 에 등록과 관련된 액션과 함수들을 만든다.

 

// articleSlice.js

postArticle: (state, action) => {},
postArticleSuccess: (state, action) => {},
postArticleFail: (state, action) => {
    state.status = action.payload?.status ?? 500;
    state.statusText = action.payload?.statusText ?? "Network Error";
},
// articleSaga.js

function apiPostArticle(requestBody) {
    return axios.post(`articles/`, requestBody);
}

function* asyncPostArticle(action) {
    try {
        const response = yield call(apiPostArticle, {
            ...action.payload,
            id: 0,
            views: 0,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response?.status === 201) {
            yield put(articleActions.postArticleSuccess());
            history.push(`/article/${response?.data?.id ?? 0}`);
        } else {
            yield put(articleActions.postArticleFail(response));
            yield alert(`등록실패 \n Error: ${response.status}, ${response.statusText}`);
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.postArticleFail(e?.response));
        yield alert(`등록실패 \n Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* watchPostArticle() {
    while(true) {
        const action = yield take(articleActions.postArticle);
        yield call(asyncPostArticle, action);
    }
}

export default function* articleSaga()
{
    yield all([fork(watchGetArticleList), fork(watchGetArticle), fork(watchUpdateArticleViews), fork(watchPostArticle)]);
}

 

등록 실패시 새로고침하여 내용을 다 날려버리는 것은 선택 사항이다.

 

필요하다면 history.go(0); 쓰면 된다.

 

 

그리고 history와 관련하여

 

utils 에 있는 history를 import하는 게 싫다면

 

import { all, call, retry, fork, put, take, select, getContext } from 'redux-saga/effects';

function* asyncPostArticle(action) {
    try {
        const history = yield getContext("history"); // react-router-dom 의 BrowserRouter 에서는 안 됨
        const response = yield call(apiPostArticle, {
            ...action.payload,
            id: 0,
            views: 0,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response?.status === 201) {
            yield put(articleActions.postArticleSuccess());
            history.push(`/article/${response?.data?.id ?? 0}`);
        } else {
            yield put(articleActions.postArticleFail(response));
            yield alert(`등록실패 \n Error: ${response.status}, ${response.statusText}`);
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.postArticleFail(e?.response));
        yield alert(`등록실패 \n Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

 

store 의 sagaMiddleware 에서 넣은 context: { history: history } 를 getContext("history") 로 꺼내와서 쓰면 된다.

초반에 말했다시피 Router 가 BrowserRouter 가 아니면 된다.

 

 

 

views/Post.js 는 다음과 같다

// Post.js

import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SELECT } from '../utils/events';
import { articleActions } from '../slices/articleSlice';

function Post() {
    const { boardList, status, statusText } = useSelector((state) => state.boardReducer);
    const [ article, setArticle ] = useState({});
    const dispatch = useDispatch();

    function onChangeArticle(e) {
        setArticle({
            ...article,
            [e.currentTarget.name]: e.currentTarget.value
        });
    }

    function onClickSubmitButton() {
        if (article?.boardId > 0 && article?.title)
        {
            dispatch(articleActions.postArticle(article));
        } else {
            alert("게시판과 제목은 필수값입니다.");
        }
    }

    
    return (
            <div>
                { status === 200 && boardList.length > 0 ?
                    (
                        <>
                            <div>
                                <span>게시판: </span>
                                <select
                                    name="boardId"
                                    onChange={onChangeArticle}
                                    value={article?.boardId ?? 0}
                                >
                                    <option value={SELECT.id} key={SELECT.id}>{SELECT.name}</option>
                                    { 
                                        boardList.map((board, index) => (
                                            <option value={board?.id} key={board?.id}>{board?.name ?? ""}</option>
                                        ))
                                    }
                                </select>
                            </div>
                            <div>
                                <span>제목: </span>
                                <input
                                    name="title"
                                    onChange={onChangeArticle}
                                    value={article?.title ?? ""}
                                />
                            </div>
                            <div>
                                <span>내용: </span>
                                <textarea
                                    name="content"
                                    onChange={onChangeArticle}
                                    value={article?.content ?? ""}
                                />
                            </div>
                            <button onClick={onClickSubmitButton}>등록</button>
                        </>
                    ) : status === 200 && boardList.length === 0 ?
                    (
                        <div>
                            게시판 등록이 필요합니다.
                        </div>
                    ) : (
                        <div>
                            <div>
                                <span>{status}</span>
                            </div>
                            <div>
                                <span>{statusText}</span>
                            </div>
                        </div>
                    )
                }
            </div>
    );
}

export default Post;

 

 

내용 잘 나오는지 확인해보자. url에도 아이디가 잘 들어가는지 보자

 

 

 

 

게시판 메뉴 눌렀을 때 목록으로도 잘 나오는지 보자

 

 

 

 

 

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

 

이번엔 Comment 에서 댓글을 조회하는 걸 만들 것이다. 운과 시간이 된다면 댓글 달기, 더 되면 댓글 삭제까지 해볼참이다.

 

 

commentSlice와 commentSaga 를 만들자.

 

 

commentSlice의 형태도 거의 똑같다.

 

// commentSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "comment";

const initialState = {
    commentList: [],
    status: 0,
    statusText: "Loading",
};

const reducers = {
    getCommentList: (state, action) => {},
    getCommentListSuccess: (state, action) => {
        state.commentList = action.payload?.data ?? [];
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    getCommentListFail: (state, action) => {
        state.commentList = initialState.commentList;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    insertComment: (state, action) => {},
    insertCommentSuccess: (state, action) => {},
    insertCommentFail: (state, action) => {},

    deleteComment: (state, action) => {},
    deleteCommentSuccess: (state, action) => {}, 
    deleteCommentFail: (state, action) => {}, 
};

const commentSlice = createSlice({
    name,
    initialState,
    reducers,
});

export const commentReducer = commentSlice.reducer;
export const commentActions = commentSlice.actions;

 

 

댓글 등록과 삭제까지 액션을 만들어주었다.

 

 

commentSaga 도 유사하다.

 

// commentSaga.js

import { all, call, fork, put, select, take } from 'redux-saga/effects';
import { commentActions } from '../slices/commentSlice';
import axios from '../utils/axios';
import qs from "query-string";

// api 서버 연결 주소
function apiGetCommentList(requestParams) {
    return axios.get(`comments?${qs.stringify(requestParams)}`);
}

function apiInsertComment(requestBody) {
    return axios.post(`comments`, requestBody);
}

function apiDeleteComment(commentId) {
    return axios.delete(`comments/${commentId}`);
}

// api 서버 연결 후 action 호출
function* asyncGetCommentList(action) {
    try {
        const response = yield call(apiGetCommentList, { articleId: action.payload });
        if (response.status === 200) {
            yield put(commentActions.getCommentListSuccess(response));
        } else {
            yield put(commentActions.getCommentListFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(commentActions.getCommentListFail(e.response));
    }
}

function* asyncInsertComment(action) {
    try {
        const article = yield select((state) => state.articleReducer.article);
        const response = yield call(apiInsertComment, {
            id: 0,
            content: action.payload,
            boardId: article.boardId,
            articleId: article.id,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response.status === 201) {
            yield put(commentActions.insertCommentSuccess());
            yield put(commentActions.getCommentList(article.id));
        } else {
            yield put(commentActions.insertCommentFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(commentActions.insertCommentFail(e.response));
        yield alert(`등록 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

function* asyncDeleteComment(action) {
    try {
        const article = yield select((state) => state.articleReducer.article);
        const response = yield call(apiDeleteComment, action.payload);
        if (response.status === 200) {
            yield put(commentActions.deleteCommentSuccess());
            yield put(commentActions.getCommentList(article.id));
        }
    } catch(e) {
        console.error(e);
        yield put(commentActions.deleteCommentFail(e.response));
        yield alert(`삭제 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

// action 호출을 감시하는 watch 함수
function* watchGetCommentList() {
    while(true) {
        const action = yield take(commentActions.getCommentList);
        yield call(asyncGetCommentList, action);
    }
}

function* watchInsertComment() {
    while(true) {
        const action = yield take(commentActions.insertComment);
        yield call(asyncInsertComment, action);
    } 
}

function* watchDeleteComment() {
    while(true) {
        const action = yield take(commentActions.deleteComment);
        yield call(asyncDeleteComment, action);
    }
}

export default function* commentSaga()
{
    yield all([fork(watchGetCommentList), fork(watchInsertComment), fork(watchDeleteComment)]);
}

 

 

댓글 등록과 삭제부분 코드를 보면

Success 이후 모두 getCommentList() 액션을 다시 호출한다.

댓글 등록이나 삭제가 반영된 db를 재조회하는 것이다.

 

// 댓글 등록

function* asyncInsertComment(action) {
    try {
        const article = yield select((state) => state.articleReducer.article);
        const response = yield call(apiInsertComment, {
            id: 0,
            content: action.payload,
            boardId: article.boardId,
            articleId: article.id,
            insertDate: Date.now(),
            updateDate: Date.now()
        });
        if (response.status === 201) {
            yield put(commentActions.insertCommentSuccess());
            yield put(commentActions.getCommentList(article.id));
        } else {
            yield put(commentActions.insertCommentFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(commentActions.insertCommentFail(e.response));
        yield alert(`등록 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

 

그리고 POST 방식의 경우 return status 가 201로 온다.

댓글의 경우 useState로 content 필드만 받아올 것이기 때문에 >>> content: action.payload,

 

boardId, articleId 는 state에서 가져와야 한다.

const article = yield select((state) => state.articleReducer.article);

boardId: article.boardId,

articleId: article.id,

 

Article.js 가 렌더링되면서 getArticle 액션이 dispatch 되는데 이때 articleReducer의 article 값이 잘 들어와야 Comment 도 같이 렌더링된다.

Saga가 제공하는 select 메서드로 article 쏙 빼온 정보만 써도 안전하다.

select를 쓴 코드의 형태를 보면 useSelector와 거의 비슷하게 생긴 것을 알 수 있다. 기능 또한 useSelector의 getState와 거의 비슷한 기능을 제공한다.

 

그리고 json-server 에서 primary key 시퀀스를 제공해주지만 id 컬럼을 필수값으로 넣어야 하고 -1은 그대로 서버에 반영되어 들어가기 때문에 나는 새글의 id는 0으로 넣어줬다.

 

 

 

// 댓글 삭제

function* asyncDeleteComment(action) {
    try {
        const article = yield select((state) => state.articleReducer.article);
        const response = yield call(apiDeleteComment, action.payload);
        if (response.status === 200) {
            yield put(commentActions.deleteCommentSuccess());
            yield put(commentActions.getCommentList(article.id));
        }
    } catch(e) {
        console.error(e);
        yield put(commentActions.deleteCommentFail(e.response));
        yield alert(`삭제 실패 Error: ${e?.response?.status}, ${e?.response?.statusText}`);
    }
}

 

 

 

다 만든 commentReducer 와 commentSaga를 rootReducer 와 rootSaga에 연결하자.

 

 

// rootReducer.js

import { combineReducers } from 'redux';
import { articleReducer } from './slices/articleSlice';
import { boardReducer } from './slices/boardSlice';
import { commentReducer } from './slices/commentSlice';

const rootReducer = combineReducers({ articleReducer, boardReducer, commentReducer });

export default rootReducer;

 

 

// rootSaga.js

import { map } from 'ramda';
import { all, fork  } from "redux-saga/effects"
import articleSaga from "./sagas/articleSaga";
import boardSaga from "./sagas/boardSaga";
import commentSaga from "./sagas/commentSaga";

let combineSagas = {};
combineSagas = Object.assign(combineSagas, { articleSaga, boardSaga, commentSaga });

export default function* rootSaga() {
    yield all(map(fork, combineSagas));
}

 

 

 

이제 Comment 에서 액션만 호출해주면 끝

 

 

// Comments.js

import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { commentActions } from '../slices/commentSlice';

function Comments({ articleId }) {
    const [ newComment, setNewComment ] = useState("");
    const { commentList, status, statusText } = useSelector((state) => state.commentReducer);
    const dispatch = useDispatch();

    function onClickInsertCommentButton() {
        dispatch(commentActions.insertComment(newComment));
        setNewComment("");
    }

    function onClickDeleteCommentButton(commentId) {
        if (!window.confirm("삭제하시겠습니까?")) return false;
        dispatch(commentActions.deleteComment(commentId));
    }

    useEffect(() => {
        dispatch(commentActions.getCommentList(articleId));
    }, [dispatch, articleId]);
    return (
        <>
            <div>
                <textarea
                    value={newComment}
                    onChange={(e) => setNewComment(e.target.value)}
                />
                <button onClick={onClickInsertCommentButton}>등록</button>
            </div>
            <div>
                {
                    status === 200 ?
                    commentList.length > 0 ?
                    commentList.map((comment, index) => (
                        <>
                            <div key={comment?.id ?? index}>
                                <span>{comment?.content ?? ""}</span>
                            </div>
                            <div>
                                <span>{(comment?.insertDate) ? new Date(comment?.insertDate).toLocaleString() : ""}</span>
                            </div>
                            <div>
                                <button onClick={() => onClickDeleteCommentButton(comment?.id ?? 0)}> X </button>
                            </div>
                        </>
                    ))
                    : <div></div>
                    :
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
                }
            </div>
        </>
    );
}

export default Comments;

 

 

조회의 경우 useSelector를 이용하여 변화된 commentList, status, statusText의 state값을 가져왔다.

 

const { commentList, status, statusText } = useSelector((state) => state.commentReducer);

{
    status === 200 ?
    commentList.length > 0 ?
    commentList.map((comment, index) => (
        <>
            <div key={comment?.id ?? index}>
                <span>{comment?.content ?? ""}</span>
            </div>
            <div>
                <span>{(comment?.insertDate) ? new Date(comment?.insertDate).toLocaleString() : ""}</span>
            </div>
            <div>
                <button onClick={() => onClickDeleteCommentButton(comment?.id ?? 0)}> X </button>
            </div>
        </>
    ))
    : <div></div>
    :
    <div>
        <div>
            <span>{status}</span>
        </div>
        <div>
            <span>{statusText}</span>
        </div>
    </div>
}}

 

 

 

 

댓글 입력은 useState를 이용했다.

댓글은 신규생성만 할 것이기 때문에 변화값을 state 안에 보관하지 않았다.

newComment 는 ""로 초기값이 설정되어 있고 onChange 이벤트가 발생할 때마다 setNewComment를 이용하여 newComment 에 바뀐 내용을 set해준다.

const [ newComment, setNewComment ] = useState("");

<div>
    <textarea
        value={newComment}
        onChange={(e) => setNewComment(e.target.value)}
    />
    <button onClick={onClickInsertCommentButton}>등록</button>
</div>

 

그리고 등록한 뒤 입력칸은 다시 빈 String 으로 바꿔준다.

 

 

function onClickInsertCommentButton() {
	dispatch(commentActions.insertComment(newComment));
	setNewComment("");
}

 

이 부분은 다시 생각하면 saga로 넘겨서 거기서 success 할 경우 setNewComment("") 처리하는 게 더 나을 거 같다.

지금 view에서 쓴 함수는 댓글 등록이 성공하든 실패하든 무조건 초기화를 해준다.

 

 

삭제의 경우 id파라미터만 보내면 되기에 간단하다.

 

function onClickDeleteCommentButton(commentId) {
    if (!window.confirm("삭제하시겠습니까?")) return false;
    dispatch(commentActions.deleteComment(commentId));
}

<button onClick={() => onClickDeleteCommentButton(comment?.id ?? 0)}> X </button>

 

버튼 onClick 이벤트에서 ()를 빼버리면 화면 로드와 동시에 이벤트를 보낼 것이다. 그러므로 빼먹지 말아야 한다.

 

 

 

 

 

 

 

 

 

db에도 잘 들어간다.

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

 

ArticleList 에서 나오는 게시글 목록 중 하나를 클릭하면

 

http://localhost:3000/article/2

 

이렇게 url이 바뀐다.

 

/article/:articleId 로 해놨으므로 param : { articleId: 2 } 이렇게 값을 받을 수 있을 것이다.

 

그래서 ArticleList 에서 boardId를 활용한 것 처럼 Article 에서도 같은 방법을 쓸 것이다.

 

 

 

그리고 앞서 만들어놓은 스토리보드/화면정의서에는 Comment 와 Article을 분리하여 그려져 있었는데 개발상 어려울 것 같아 Article 컴포넌트 안에 Comment 컴포넌트롤 넣기로 하였다.

 

 

그래서 Article.js 는 이런 구조로 간다.

 

// Article.js

import React from 'react';
import Comment from './Comments';

function Article() {
    return (
        <>
            <div>
                게시글 상세
            </div>
            <div>
                <Comment />
            </div>
        </>
    );
}

export default Article;

 

 

 

 

 

 

articleSlice 의 initialState 에 article: {} 를 추가하고 reducer에는  getArticle 과 upadateArticleViews 액션들을 추가한다.

 

 

// articleSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "article";

const initialState = {
    article: {}, // 추가
    articleList: [],
    status: 0,
    statusText: "Loading",
};

const reducers = {
    getArticleList: (state, action) => {},
    getArticleListSuccess: (state, action) => {
        state.articleList = action.payload?.data ?? [];
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    getArticleListFail: (state, action) => {
        state.articleList = initialState.articleList;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },

    getArticle: (state, action) => {}, // 추가
    getArticleSuccess: (state, action) => {}, // 추가
    getArticleFail: (state, action) => { // 추가
        state.article = initialState.article;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },
    
    updateArticleViews: (state, action) => {}, // 추가
    updateArticleViewsSuccess: (state, action) => { // 추가
        state.article = action.payload?.data ?? {};
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    updateArticleViewsFail: (state, action) => { // 추가
        state.article = initialState.article;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    }
};

const articleSlice = createSlice({
    name,
    initialState,
    reducers,
});

export const articleReducer = articleSlice.reducer;
export const articleActions = articleSlice.actions;

 

slice에 액션이 만들어졌으니 낚아챌 async함수를 articleSaga 에 만들자.

 

 

// articleSaga.js

import { all, call, retry, fork, put, take, select } from 'redux-saga/effects';
import { articleActions } from '../slices/articleSlice';
import axios from '../utils/axios';
import qs from "query-string";

const SECOND = 1000;

// api 서버 연결 주소
function apiGetArticle(articleId) {
    return axios.get(`articles/${articleId}`);
}

function apiGetArticleList(requestParams) {
    return axios.get(`articles?${qs.stringify(requestParams)}`);
}

function apiPutArticle(requestBody) {
    return axios.put(`articles/${requestBody?.id}`, requestBody);
}

const SECOND = 1000;

// api 서버 연결 주소
function apiGetArticle(articleId) {
    return axios.get(`articles/${articleId}`);
}

function apiGetArticleList(requestParams) {
    return axios.get(`articles?${qs.stringify(requestParams)}`);
}

function apiPutArticle(requestBody) {
    return axios.put(`articles/${requestBody?.id}`, requestBody);
}

// api 서버 연결 후 action 호출
function* asyncGetArticleList(action) {
    try {
        // const response = yield call(apiGetArticleList, { boardId: action.payload });
        const response = yield retry(3, 10 * SECOND, apiGetArticleList, { boardId: action.payload });
        if (response?.status === 200) {
            yield put(articleActions.getArticleListSuccess(response));
        } else {
            yield put(articleActions.getArticleListFail(response));
        }
    } catch(e) {
        yield put(articleActions.getArticleListFail(e.response));
    }
}

function* asyncGetArticle(action) {
    try {
        const response = yield call(apiGetArticle, action.payload);
        if (response?.status === 200) {
            yield put(articleActions.getArticleSuccess()); // 조회 성공확인만 판단하는 용도로 남김
            yield put(articleActions.updateArticleViews(response.data));
        } else {
            yield put(articleActions.getArticleFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.getArticleFail(e.response));
    }
}

function* asyncUpdateArticleViews(action) {
    try {
        const response = yield call(apiPutArticle, {
            ...action.payload,
            views: parseInt(action.payload?.views ?? 0) + 1,
            updateDate: Date.now()
        });
        if (response?.status === 200) {
            yield put(articleActions.updateArticleViewsSuccess(response));
        } else {
            yield put(articleActions.updateArticleViewsFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.updateArticleViewsFail(e?.response));
    }
}

// action 호출을 감시하는 watch 함수
function* watchGetArticleList() {
    while(true) {
        const action = yield take(articleActions.getArticleList);
        yield call(asyncGetArticleList, action);
    }
}

function* watchGetArticle() {
    while(true) {
        const action = yield take(articleActions.getArticle);
        yield call(asyncGetArticle, action);
    } 
}

function* watchUpdateArticleViews() {
    while(true) {
        const action = yield take(articleActions.updateArticleViews);
        yield call(asyncUpdateArticleViews, action);
    }
}

export default function* articleSaga()
{
    yield all([fork(watchGetArticleList), fork(watchGetArticle),
        fork(watchUpdateArticleViews)]);
}

 

 

java를 이용했을 경우 조회와 동시에 views 업데이트할 수 있는데 json-server는 어떻게 할지 방도를 모르겠다.

 

그래서 조회해옴과 동시에 조회 업데이트 치는 액션을 날린다.

 

근데 이 방법의 단점은 getArticle 액션을 dispatch할 때마다 조회수가 update가 되므로 다른 때에 재활용을 못한다는 것이다.

 

function* asyncGetArticle(action) {
    try {
        const response = yield call(apiGetArticle, action.payload);
        if (response?.status === 200) {
            yield put(articleActions.getArticleSuccess()); // 조회 성공확인만 판단하는 용도로 남김
            yield put(articleActions.updateArticleViews(response.data)); // 조회수 업데이트 액션 호출
        } else {
            yield put(articleActions.getArticleFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.getArticleFail(e.response));
    }
}

 

 

apiPutArticle 함수는 게시글 수정시에도 재활용될 것이다. 그래서 이름을 아예 apiPutArticle 으로 하였다!

 

function apiPutArticle(requestBody) {
    return axios.put(`articles/${requestBody?.id}`, requestBody);
}

function* asyncUpdateArticleViews(action) {
    try {
        const response = yield call(apiPutArticle, {
            ...action.payload,
            views: parseInt(action.payload?.views ?? 0) + 1,
            updateDate: Date.now()
        });
        if (response?.status === 200) {
            yield put(articleActions.updateArticleViewsSuccess(response));
        } else {
            yield put(articleActions.updateArticleViewsFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(articleActions.updateArticleViewsFail(e?.response));
    }
}

 

조회수의 경우 다른 정보는 모두 같고 조회수만  +1이므로 { ...action.payload(게시글정보) } spread 배열을 이용하여 views 와 updateDate 만 덮어씌워줬다.

 

 

이제 /views/Article.js 를 보자.

 

// Article.js

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Comment from './Comments';

function Article() {
    const params = useParams();
    const dispatch = useDispatch();
    useEffect(() => {
        dispatch(articleActions.getArticle(params?.articleId ?? 0));
    }, [dispatch, params?.articleId]);
    return (
        <>
            <div>
                게시글 상세
            </div>
            <div>
                <Comment />
            </div>
        </>
    );
}

export default Article;

 

useParam으로 articleId를 가져왔고 그걸 getArticle의  action.payload로 태워 dispatch 하였다.

 

 

 

 

 

redux devTools 을 켜서 잘 작동됐나 봐보자.

 

 

 

이제 Article.js 에 값을 뿌려줄 차례다.

 

Comment 에서 댓글리스트를 조회하려면 articleId 값이 있어야 조회가 가능하므로 props로 articleId를 넘겨주는 부분을 추가해주자.

 

그리고 현재 어떤 게시판 소속인지 알려주기 위해서  게시판 명도 자리도 만들겠다.

 

// Article.js

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import Comment from './Comments';
import { articleActions } from '../slices/articleSlice';

function Article() {
    const params = useParams();
    const { article, status, statusText } = useSelector((state) => state.articleReducer);
    const boardList = useSelector((state) => state.boardReducer.boardList);
    const dispatch = useDispatch();
    const history = useHistory();

    useEffect(() => {
        dispatch(articleActions.getArticle(params?.articleId ?? 0));
    }, [dispatch, params?.articleId]);
    return (
        <>
            {
                status === 200 ?
                    <>
                        <div>
                            <span>게시판: </span>
                            <span>
                            {
                                boardList.length > 0 &&
                                boardList.find((board) => board.id === parseInt(article?.boardId))?.name
                            }
                            </span>
                        </div>
                        <div>
                            <div><span>제목: </span><span>{article?.title ?? ""}</span></div>
                            <div><span>조회수: </span><span>{article?.views ?? ""}</span></div>
                            <div><span>작성일시: </span><span>{(article.insertDate) ? new Date(article?.insertDate).toLocaleString() : ""}</span></div>
                            <div><span>내용: </span><span>{article?.content ?? ""}</span></div>
                        </div>
                        <div>
                            <Comment articleId={params?.articleId ?? 0} />
                        </div>
                    </>
                :
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
            }
        </>
    );
}

export default Article;

 

게시판 명은 추가로 조회 안 하고 있는 state에서 꺼내왔다.

 

선호하지 않는 방식이지만.. 게시판 명 하나니까

 

{
    boardList.length > 0 &&
    boardList.find((board) => board.id === parseInt(article?.boardId))?.name
}

 

그리고 중요한 건 url 파라미터 특성답게 param 은 String으로 온다.

그래서 쓰려면 형변환해줘야 한다.

 

 

 

json-server 만 아니었으면 서브쿼리로 게시판명을 같이 조회해오거나 게시글 entity에 join 되어있는 게시판 정보에서 그냥 게시판명 꺼내 썼을 것이다.

 

 

 

<div>
	<span>작성일시: </span><span>{(article.insertDate) ? new Date(article?.insertDate).toLocaleString() : ""}</span>
</div>

 

작성일시의 경우 new Date(불러온 값).toString() 을 해주는 이유는 리액트에서 Date 형태는 바로 렌더링되지 않기 때문이다.

 

 

 

게시글을 클릭해보고 내용이 잘 나오는지 확인해보자.

 

 

 

새로고침할 때마다 조회수가 증가하는 것을 알 수 있다.

 

 

json-server에도 잘 나오는지 터미널을 종종 확인해줘야 한다.

api 경로까지 확인할 수 있다.

 

 

 

 

 

 

원래 조회수 update 방법으로 patch를 사용하려 했었다.

patch 도 REST API 중 하나인데 딱 한 곳만 콕 집어 수정할 때 사용한다.

 

나도 최근에 안 개념이다.

 

axios-http.com/docs/api_intro/

 

Axios API | Axios Docs

Axios API The Axios API Reference Requests can be made by passing the relevant config to axios. axios(config) axios({ method: 'post', url: '/user/12345', data: { firstName: 'Fred', lastName: 'Flintstone' } }); axios({ method: 'get', url: 'http://bit.ly/2mT

axios-http.com

 

velog.io/@insutance/REST-API-HTTP-Method-PUT-vs-PATCH

 

[REST API] HTTP Method PUT vs PATCH

restful API 를 공부하던 와중 update 부분을 만들 때 , PUT 을 사용해야 한다는 걸 알게 되었다.하지만 구글링을 통해 또 알게 된 내용은 update 를 PATCH 를 통해서 하는 방법도 있다는 걸 알게 되었다.이

velog.io

 

 

설명을 더 찾아보니 일부 웹서버나 브라우저는 지원을 안 한다고 한다. 그리고 최근에 생긴 개념이라 하는데. 오히려 권장이 안 되는 건지..

 

 

redux-advanced.vlpt.us/3/01.html

 

3-1. json-server 이해하기 · GitBook

3-1 json-server 사용하기 json server 는 아주 짧은 시간에 REST API 를 구축해주는 라이브러리입니다. 하지만, REST API 서버의 기본적인 기능을 대부분 갖추고 있는데요, 프로덕션 전용은 아닙니다. 프로

redux-advanced.vlpt.us

 

json-server 는 patch룰 지원하는 것 같다.

 

 

 

블로그 포스팅하기 전에 소스를 먼저 작성해서 테스트한 뒤 잘 되면 그 때 작성하는 편이라 patch도 사실 시도해봤는데

 

 

 

 

server 와의 연결은 되는데! views가 업데이트가 안된다!!!!!!!

 

 

 

그래서 포스팅은 put 을 쓴 방법이 올라갔다.

 

생각해보니 views 업데이트될 때 views 말고도 updateDate 도 업데이트 해줘야 한다. 결론은 put 쓰길 잘했다로!^^

 

json-server에서 put으로 업데이트하려면 모든 컬럼, 컬럼 value들을 가지고 있어야 한다.

 

 

 

 

 

만약 존재하지 않는 게시글 아이디를 url에 입력한다면

 

이렇게 뜬다.

 

 

 

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형
반응형

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

 

 

조회 방식은 지난번 게시판 board 불러오는 것과 거의 비슷하다. 그러나 이번에는 articles 내 데이터 중 원하는 boardId 를 가진 것들만 불러올 것이다.

 

 

게시글 목록 불러오는 것과 한 게시글의 내용을 불러오는 것은 같은 slice와 saga를 쓸 것이다. (articleSlice, articleSaga)

 

 

Routes에서 정해놓은 바에 따라 게시판 중 "일기" 와 "할일" 을 클릭하면 ArticleList 컴포넌트가 렌더링될 것이다.

 

// Routes.js
const ArticleList = lazy(() => import('../views/ArticleList'));
<Route path={"/board/:boardId"} exact component={ArticleList} />

// Board.js
<Link to={{ pathname: `/board/${board?.id}` }}>

 

 

 

나는 url에서 게시판 id  parameter 만 받아와서 조회하면 된다.

 

쓸 훅은 useParams, useSelector, useEffect가 다일 듯하다.

 

껍데기만 만들어놓은 articleSlice에 initialState 와 reducers 내용을 만들어주자.

 

 

// artilceSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "article";

const initialState = {
    articleList: [],
    status: 0,
    statusText: "Loading",
};

const reducers = {
    getArticleList: (state, action) => {},
    getArticleListSuccess: (state, action) => {
        state.articleList = action.payload?.data ?? [];
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    getArticleListFail: (state, action) => {
        state.articleList = initialState.articleList;
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    }
}

const articleSlice = createSlice({
    name,
    initialState,
    reducers
});

export const articleReducer = articleSlice.reducer;
export const articleActions = articleSlice.actions;

 

이름만 바뀌었을 뿐이지 boardSlice.js 와 거의 똑같다.

 

 

articleSaga.js 도 마찬가지다.

 

너무 똑같아서 변화를 주기로 하였다. 큰 변화는 아니고 다른 메서드 사용을 시도해보겠다.

 

// articleSaga.js

import { all, call, retry, fork, put, take, select } from 'redux-saga/effects';
import { articleActions } from '../slices/articleSlice';
import axios from '../utils/axios';
import qs from "query-string";

const SECOND = 1000;

// api 서버 연결 주소
function apiGetArticle(articleId) {
    return axios.get(`articles/${articleId}`);
}

function apiGetArticleList(requestParams) {
    return axios.get(`articles?${qs.stringify(requestParams)}`);
}

// api 서버 연결 후 action 호출
function* asyncGetArticleList(action) {
    try {
        // const response = yield call(apiGetArticleList, { boardId: action.payload });
        const response = yield retry(3, 10 * SECOND, apiGetArticleList, { boardId: action.payload });
        if (response?.status === 200) {
            yield put(articleActions.getArticleListSuccess(response));
        } else {
            yield put(articleActions.getArticleListFail(response));
        }
    } catch(e) {
        yield put(articleActions.getArticleListFail(e.response));
    }
}

// action 호출을 감시하는 watch 함수
function* watchGetArticleList() {
    while(true) {
        const action = yield take(articleActions.getArticleList);
        yield call(asyncGetArticleList, action);
    }
}

export default function* articleSaga()
{
    yield all([fork(watchGetArticleList)]);
}

 

boardSaga 와 다른 점은 우선 query-string 이 사용됐다 (이건 위에서 말한 변화가 아니다.)

 

 

function apiGetArticleList(requestParams) {
    return axios.get(`articles?${qs.stringify(requestParams)}`);
}

 

requestParam 은 { baordId: 숫자 } 이고

query-string은 이걸 url 의 query-string으로 바꾸어준다.

 

그래서 호출되는 url은

articles?boardId=숫자 가 된다.

 

 

 

변화를 준 부분은 이 부분이다.

const response = yield retry(3, 10 * SECOND, apiGetArticleList, { boardId: action.payload });

 

retry 메서드는 call 을 가지고 있다. 다만 이름 그대로 retry로 주어진 조건에 따라 call 을 계속 시도하는 것이다.

 

redux-saga.js.org/docs/api/#retrymaxtries-delay-fn-args

 

API Reference | Redux-Saga

API Reference

redux-saga.js.org

 

위 API Reference를 참고하여 따라해보았다.

 

yield retry(횟수, 시간(mills), 호출함수, 호출함수input);

 

형식은 이러하다.

 

첫번째 call이 실패했을 때 정해진 시간 간격마다 정해진 횟수 안에 연결이 성공할 때까지 call을 시도한다.

 

 

// rootSaga.js

import { map } from 'ramda';
import { all, fork  } from "redux-saga/effects"
import articleSaga from "./sagas/articleSaga";
import boardSaga from "./sagas/boardSaga";

let combineSagas = {};
combineSagas = Object.assign(combineSagas, { articleSaga, boardSaga });

export default function* rootSaga() {
    yield all(map(fork, combineSagas));
}

 

rootSaga 에 articleSaga를 넣어주고

 

 

 

이제 ArticlesList.js 에서는 훅만 제대로 import만 해주면 된다! 형태도 Board와 거의 비슷하다.

 

// ArticleList.js

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useParams } from 'react-router-dom';
import { articleActions } from '../slices/articleSlice';

function ArticleList() {
    const params = useParams();
    const { articleList, status, statusText } = useSelector((state) => state.articleReducer);
    const boardList = useSelector((state) => state.boardReducer.boardList);
    const dispatch = useDispatch();
    useEffect(() => {
        dispatch(articleActions.getArticleList(params?.boardId ?? 0));
    }, [dispatch, params?.boardId]);
    return (
        <>
            {
                status === 200 ?
                    <>
                        <div>
                            <span>게시판: </span>
                            <span>
                                {
                                    boardList.length > 0 &&
                                    boardList.find((board) => board.id === parseInt(params?.boardId))?.name
                                }
                            </span>
                        </div>
                        { articleList.length > 0 ?
                            <div>
                                <div>
                                    {
                                        articleList.map((article, index) => 
                                            <div  key={article?.id ?? index}>
                                                <Link to={{ pathname: `/article/${article?.id ?? 0}` }}>
                                                    <span>{article?.title ?? ""}</span>
                                                </Link>
                                            </div>
                                        )

                                    }
                                </div>
                            </div>
                        :
                            <div> 게시글이 없습니다. </div>
                        }
                    </>
                :
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
            }
        </>
    );
}

export default ArticleList;

 

useParams 는 url 중 parameter로 보낸 것만 받아주는 훅이다.

 

url에서 /숫자?  숫자를 보통 parameter(param)로 칭하고 ?key=value&key=value 이걸 query로 칭하는데 useParam 은 딱 param까지만 가져와준다.

 

 

다른 정보까지 얻고 싶다면 react-router 들의  훅 useRouterMatch, useLocation 이 있는데

 

const match = useRouterMatch();

const location = useLocation();

을 작성한 뒤 console에 값이 어떻게 나오는지 확인하는 것도 추천한다.

 

react-router 는 알면 알수록 재밌다.

 

 

 

화면이 제대로 나온다면 이렇게 나올 것이다.

 

 

 

 

 

 

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

 

반응형
반응형

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

 

들어가기 전에 파일 구조를 맞추고 들어가겠다.

 

Board.js

 

// Board.js

import React from 'react';
import { Link } from "react-router-dom";

function Board() {
    return (
        <div>
            <ul>
                <li>
                    <Link to="/">
                        <span>Main</span>
                    </Link>
                </li>
                <li>
                    <Link to="/board/1">
                        <span>board1</span>
                    </Link>
                </li>
                <li>
                    <Link to="/board/2">
                        <span>board2</span>
                    </Link>
                </li>
            </ul>
        </div>
    );
}

export default Board;

 

일단 sidebar 역할을 하는 Board 안의 board를 조회해오겠다.

 

 

순서는 boardSlice 만들기 -> boardSlice의 boardReducer 를 rootReducer 에 집어넣기

-> boardSaga 만들기 -> rootSaga에 boardSaga 넣기 -> Board에서 액션 호출하기

 

 

이렇게 할 것이다.

 

파일 생성이 끝난 후에 state를 보는 법도 설명하겠다.

 

 

우선 /src/slices 에 boardSlice 와 articleSlice를 만든다.

 

// boardSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "board";

const initialState = {};

const reducers = {};

const boardSlice = createSlice({
    name,
    initialState,
    reducers
});

export const boardReducer = boardSlice.reducer;
export const boardActions = boardSlice.actions;

 

// articleSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "article";

const initialState = {};

const reducers = {};

const articleSlice = createSlice({
    name,
    initialState,
    reducers
});

export const articleReducer = articleSlice.reducer;
export const articleActions = articleSlice.actions;

 

 

createSlice 안에서 name, initialState, reducers 를 직접 설정해줘도 좋다.

 

밖에서 객체로 만들어 넣어줘도 동작은 잘 한다.

 

 

 

그리고 rootReducers 에 만든 boardReducer를 import 하여 연결해준다.

 

 

rootReducer.js

import { combineReducers } from 'redux';
import { articleReducer } from './slices/articleSlice';
import { boardReducer } from './slices/boardSlice';

const rootReducer = combineReducers({ articleReducer, boardReducer });

export default rootReducer;

 

 

 

여기서부터 새 reducer가 추가된 state 형태를 볼 수 있는데 우선 크롬에 리덕스 개발자도구 (redux dev tools) extension이 추가되어 있어야 한다.

 

 

(현재 포스팅한 모든 글은 크롬 웹브라우저에서 화면을 확인한다.)

 

 

크롬에서 redux extension 만 구글링해줘도 나오는데

 

 

redux dev tools 를 클릭하여 들어간다.

 

 

 

 

파란 버튼을 누르고 설치가 완료되면 파란버튼이 "chrome에서 삭제"로 바뀔 것이다.

 

 

 

 

그리고 새 창을 띄워 localhost:3000 을 입력해서 다시 들어간 뒤에

 

f12 를 눌러본다 (혹은 마우스 오른쪽 버튼 클릭 + 검사)

 

 

위 항목 중 "Redux" 로 들어간다.

 

 

 

그럼 오른쪽에 action, state, diff, trace, test 가 있을 것인데

 

우리가 자주 사용할 것은 action, state 부분이다

 

action은 action호출했을 시 type, payload를 확인할 것이고

 

state 는 액션이 호출됐을 시점의 state를 보여준다.

 

 

일단 state를 눌러보면

 

 

 

 

방금 만든 articleReducer와 boardReducer 가 들어가 있을 것이다.

 

 

이 이유는 store 에 reducer로서 rootReducer를 올려놨기 때문이다.

 

 

 

 

 

boardSlice 안 initialState 안에 내용을 추가해보고 redux dev tool 에 어떻게 나오는지 확인해보자!

 

import { createSlice } from "@reduxjs/toolkit";

const name = "board";

const initialState = {
    boardList: [], // 추가
};

const reducers = {};

const boardSlice = createSlice({
    name,
    initialState,
    reducers
});

export const boardReducer = boardSlice.reducer;
export const boardActions = boardSlice.actions;

 

initiaState 는 object 형태로 되어있어서 안에 내가 받고 싶은 자료의 이름을 key로, value에는 자료형까지도 미리 선언해놓을 수 있다.

 

 

redux dev tools 의 state를 확인해보면 (안 보일 시 새로고침 필요)

 

 

 

boardList: []가 들어가있는 걸 볼 수 있다.

 

 

이제 마저 action을 만들자!

 

// boardSlice.js

import { createSlice } from "@reduxjs/toolkit";

const name = "board";

const initialState = {
    boardList: [],
};

const reducers = {
    getBoardList: (state, action) => {}, // view에서 호출
    getBoardListSuccess: (state, action) => {}, // saga에서 api 연결 성공시 return 값 적용
    getBoardListFail: (state, action) => {}, // api 연결 실패시 return 값 적용
};

const boardSlice = createSlice({
    name,
    initialState,
    reducers
});

export const boardReducer = boardSlice.reducer;
export const boardActions = boardSlice.actions;

 

 

saga로 가야 하지만 api연결이 먼저 필요하다. 

 

 

/utils 에 axios.js 를 만들자.

 

 

// /utils/axios.js

import Axios from "axios";

const axiosInstance = Axios.create({
    baseURL: "http://localhost:4000/",
    timeout: 3000,
});

export default axiosInstance;

 

간단한 json-server를 이용하는 것이기 때문에 config 없이 이렇게만 만든다.

 

 

axios 모듈 호출을 한 곳에서만 관리하기 위해 axios.js 파일을 따로 만든 것이다.

 

 

/src/sagas 에 boardSaga.js 를 생성한다

 

// /sagas/boardSaga.js

import { all, call, fork, put, take } from 'redux-saga/effects';
import { boardActions } from '../slices/boardSlice';
import axios from '../utils/axios';

// api 서버 연결 주소
function apiGetBoard(boardId) {
    return axios.get(`boards/${boardId}`);
}

function apiGetBoardList() {
    return axios.get(`boards`);
}

// api 서버 연결 후 action 호출
function* asyncGetBoardList() {
    try {
        const response = yield call(apiGetBoardList);
        console.log(response);
    } catch(e) {
        console.error(e);
        yield put(boardActions.getBoardListFail(e.response));
    }
}

// action 호출을 감시하는 watch 함수
function* watchGetBoardList() {
    while(true) {
        yield take(boardActions.getBoardList);
        yield call(asyncGetBoardList);
    }
}

export default function* boardSaga()
{
    yield all([fork(watchGetBoardList)]);
}

 

function 옆 *은 오타가 아니다. 제너너레이터 함수다.

 

ko.javascript.info/generators

 

제너레이터

 

ko.javascript.info

 

그리고 yield는 꼭 써줘야 한다.

 

 

json-server 의 response가 어떻게 오는지 확인하기 위해 console.log(response)로 값을 확인하는 것을 추천한다.

 

 

 

이제 rootSaga에 생성한 boardSaga를 연결한다.

 

 

// rootSaga.js

import { map } from 'ramda';
import { all, fork  } from "redux-saga/effects"
import boardSaga from "./sagas/boardSaga";

let combineSagas = {};
combineSagas = Object.assign(combineSagas, { boardSaga }); // 수정부분

export default function* rootSaga() {
    yield all(map(fork, combineSagas));
}

 

 

이 포스팅 작성하기 전까지 saga 동작이 안됐었는데 boardSaga를 객체 형태로 넣어주지 않아서 그랬다.

 

도움을 주신 christopher 님께 감사를

 

 

 

이제 정말로  Board에서 action을 호출할 것이다.

 

먼저 필요한 훅 import

 

import React, { useEffect } from 'react';
import { useDispatch } from 'react-redux';

 

Board 컴포넌트 안에

const dispatch = useDispatch();

 

액션을 호출할 useDispatch() 훅을 dispatch 안에 담아준다. 

 

그리고 화면을 로드하자마자 dispatch 함수를 호출해줄 useEffect 구문을 작성한다.

 

useEffect(() => {
    dispatch(boardActions.getBoardList());
}, [dispatch])

 

 

총 코드는 이러하다.

 

// Board.js

import React, { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { boardActions } from '../slices/boardSlice';

function Board() {
    const dispatch = useDispatch();
    useEffect(() => {
        dispatch(boardActions.getBoardList());
    }, [dispatch])
    return (
        <div>
            <ul >
                <li >
                    <Link to="/">
                        <span>Main</span>
                    </Link>
                </li>
                <li >
                    <Link to="/board/1">
                        <span>board1</span>
                    </Link>
                </li>
                <li >
                    <Link to="/board/2">
                        <span>board2</span>
                    </Link>
                </li>
            </ul>
        </div>
    );
}

export default Board;

 

 

 

response 의 status 가 200 이면 성공이라는 뜻이다. 그리고 data 안에 우리가 원하는 값이 들어있다.

 

 

 

response.data 값을 쉽게 보기 위해 console.table(response.data) 를 써보겠다.

 

 

 

 

아름답다 아름다와

 

 

response.status를 통해서 우리가 원할 때 원하는 메서드를 이용하게 할 수 있다.

 

나는 다음과 같이 만들었다.

 

 

// boardSaga.js

import { all, call, fork, put, take } from 'redux-saga/effects';
import { boardActions } from '../slices/boardSlice';
import axios from '../utils/axios';

// api 서버 연결 주소
function apiGetBoard(boardId) {
    return axios.get(`boards/${boardId}`);
}

function apiGetBoardList() {
    return axios.get(`boards`);
}

// api 서버 연결 후 action 호출
function* asyncGetBoardList() {
    try {
        const response = yield call(apiGetBoardList);
        if (response?.status === 200) {
            yield put(boardActions.getBoardListSuccess(response));
        } else {
            yield put(boardActions.getBoardListFail(response));
        }
    } catch(e) {
        console.error(e);
        yield put(boardActions.getBoardListFail(e.response));
    }
}

// action 호출을 감시하는 watch 함수
function* watchGetBoardList() {
    while(true) {
        yield take(boardActions.getBoardList);
        yield call(asyncGetBoardList);
    }
}

export default function* boardSaga()
{
    yield all([fork(watchGetBoardList)]);
}

 

 

보통 개발 시 의도한 결과대로만 동작을 하기 때문에 연결 실패 시 어떻게 동작할지도 생각해줘야 한다. 그래서 나는 연결 실패의 경우도 추가해줄 것이다.

 

사용하는 apiGetBoardList안 url 주소를 boards 에서 board로 바꾼다.

 

 

function apiGetBoardList() {
    return axios.get(`board`);
}

 

 

그럼 status 404 가 뜰 것이다.

 

연결이 실패할 경우 액션 getBoardListFail 을 타게 해놨으므로 boardSlice 내 getBoardListFail 에서 값을 가공하면 된다.

 

나는 response 의 값에 따라 이렇게 작성하였다.

 

//boardSlice.js

//...

const initialState = {
    boardList: [],
    status: 0,
    statusText: "Loading"
};

const reducers = {
    getBoardList: (state, action) => {},
    getBoardListSuccess: (state, action) => {
        state.boardList = action.payload?.data ?? [];
        state.status = action.payload?.status;
        state.statusText = action.payload?.statusText ?? "Success";
    },
    getBoardListFail: (state, action) => {
        state.boardList = initialState.boardList
        state.status = action.payload?.status ?? 500;
        state.statusText = action.payload?.statusText ?? "Network Error";
    },
};

 

redux devTools 에서 state 값을 확인해 보면

 

 

 

잘 들어가 있다.

 

 

흔히 잘 보던 404 Not Found!

 

 

이제 에러가 날 경우 Board에 어떻게 나오는지 만들어줘야 한다

 

boardReducer의 state 구독할 useSelector를 만들면 된다.

 

 

// Board.js

import { useDispatch, useSelector } from 'react-redux';

 

const { boardList, status, statusText } = useSelector((state) => state.boardReducer);

 

 

그리고 JSX return 부분을 수정한다.

 

// Board.js

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { boardActions } from '../slices/boardSlice';

function Board() {
    const { boardList, status, statusText } = useSelector((state) => state.boardReducer);
    
    const dispatch = useDispatch();
    useEffect(() => {
        dispatch(boardActions.getBoardList());
    }, [dispatch]);
    return (
        <>
            { 
                status === 200 ? 
                <div>
                	<ul>
                    	<li>
                        	<Link to="/">
                            	<span>Main</span>
                            </Link>
                        </li>
                        <li>
                        	<Link to="/board/1">
                            	<span>board1</span>
                            </Link>
                        </li>
                        <li>
                        	<Link to="/board/2">
                        		<span>board2</span>
                        	</Link>
                        </li>
                    </ul>
                </div>
                : 
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
            }
        </>
    );
}

export default Board;

 

 

에러가 발생했을 때는 이런 화면이 뜬다

 

 

 

 

boardSaga 에서  return axios.get(`board`);로 잠깐 바꿔놓았던 것을 다시 return axios.get(`boards`);로 고쳐놓으면 sidebar 가 잘 보인다.

 

 

// boardSlice.js

getBoardListFail: (state, action) => {
    state.boardList = initialState.boardList
    state.status = action.payload?.status ?? 500;
    state.statusText = action.payload?.statusText ?? "Network Error";
},

 

여기서 ??  null 병합 연산자인데

 

x = a ?? b

 

a 가 undefined 혹은 null 일 경우에는 b를 x 에 담으라는 코드이다.

 

따라서 boardSlice는 json-server 가 꺼져 아무런 response를 보내지 않으면 undefined로 올 것인데 그 경우 status 는 500으로 statusText는 Network Error 가 담기게 된다.

 

 

 

그리고 혹시 ?. 와 ?? 를 처음 본다면 아래 포스팅을 참고하길 바란다.

 

binaryjourney.tistory.com/52

 

[Javascript] Optional Chaining ( ?.)

오늘 신기한 걸 알게 돼서 포스팅을 해본다. 동료분도 지금 리액트 독학 중이신데 ?. << 이걸 처음 써봤다고 알려주셨다. 현재 우리가 계속 써왔던 방식은 const keyName = (object.hasOwnProperty("keyName") ? o

binaryjourney.tistory.com

 

 

 

이젠 받아온 값을 map 함수를 이용하여 뿌릴 것이다

 

 

map 함수가 문제없이 잘 돌아가려면 값이 무조건 배열 형태로 잘 왔는지 확인해야 한다.

 

 

// Board.js

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { boardActions } from '../slices/boardSlice';

function Board() {
    const { boardList, status, statusText } = useSelector((state) => state.boardReducer);
    
    const dispatch = useDispatch();
    useEffect(() => {
        dispatch(boardActions.getBoardList());
    }, [dispatch]);
    return (
        <>
            { 
                status === 200 ? 
                    <div>
                        <ul >
                            <li key={0}>
                                <Link to="/">
                                    <span>Main</span>
                                </Link>
                            </li>
                            {
                                boardList.length > 0 ?
                                boardList.map((board) => (
                                    <li  key={board?.id}>
                                        <Link to={{ pathname: `/board/${board?.id}` }}>
                                            <span>{board?.name}</span>
                                        </Link>
                                    </li>
                                ))
                                : <div> 게시판이 없습니다. </div>

                            }
                        </ul>
                    </div>
                : 
                    <div>
                        <div>
                            <span>{status}</span>
                        </div>
                        <div>
                            <span>{statusText}</span>
                        </div>
                    </div>
            }
        </>
    );
}

export default Board;

 

 

총 수정된 코드는 다음과 같다.

 

 

 

 

 

 

목차돌아가기:

binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial-v2

 

[React][CRUD] create-board-tutorial-v2

* 인용시 출처 부탁드립니다. 완성 소스 code: github.com/cruellaDev/react-create-board-v2 cruellaDev/react-create-board-v2 updated version of react-create-board. Contribute to cruellaDev/react-create-..

binaryjourney.tistory.com

 

반응형

+ Recent posts