0%

Diary App 만들기 - 2. Diary 구현

Diary App

6️⃣

NewCard에 수정 API 연결하기

api.js

card를 추가함에 따라 기존의 rawData를 수정해야 한다. rawData[year][month]에 카드를 추가하면 데이터가 수정되기 때문. 하지만 json-server에는 수정 API (PUT method) 가 없기 때문에, 기존의 데이터에 수정된 데이터를 붙여 새로운 데이터를 만들어 POST 해줘야 한다.

PUT과 POST의 차이는 다음과 같다.

The PUT method requests that the enclosed entity be stored under the supplied Request-URI. If the Request-URI refers to an already existing resource, the enclosed entity SHOULD be considered as a modified version of the one residing on the origin server.

1
2
3
4
const instance = axios.create({
baseURL: "http://localhost:3001",
timeout: 1000
});

똑같은 instance를 사용한 method가 2개 이상이 됨에 따라 instance를 생성하여 get , post 와 같은 method를 이용하였다. 이 때 timeout 이란, 해당 시간 동안 (이는 ms 기준이다!) 답을 얻지 못하면 block을 시켜버리는 것이다. 이 시간을 잘 조절해야 한다… (1000ms로 안 되는 데이터 송수신 양은 뜻하지 않게 에러를 부를 수 있다…)

1
2
3
4
5
6
7
8
export const createCardData = async (userData) => {
try {
const rawData = await instance.post("/posts", {
data: userData
});
console.log("[SUCCESS] POST card data");
return rawData.data.data;

수정할 데이터를 인자로 받으면, 이를 post 에 데이터로 전달한다. return data는 rawData라는 data 객체에서 data 파트의 우리가 정의한 data를 읽어와야 하기 때문에 rawData.data.data 가 된다.

Main

1
2
const [userData, setUserData] = useState(null);
const [rawData, setRawData] = useState(null);

api로부터 받는 rawData 자체와 이를 year, month에 맞게 가져오는 userData 를 따로 정의한다.

1
2
3
4
5
6
7
useEffect(() => {
(async () => {
const data = await getCardData();
setRawData(data);
data[year] && setUserData(data[year][month]);
})();
}, [year, month]);

getCardData 로 api를 호출하고 이를 rawData로 세팅한다. 이후 데이터가 존재하면, userData에 year과 month를 알맞게 전달하여 데이터를 받아 세팅한다.

따로 받아오는 이유는 다음과 같은데,

1
2
3
4
{userData &&
userData.map((data, index) => {
return <Card key={index} userData={data} />;
})}

Card 에는 userData 를 전달해 데이터를 올바르게 읽어와야 하고,

1
2
3
4
5
6
<NewCard
year={year}
month={month}
rawData={rawData}
setUserData={setUserData}
/>

NewCard 에는 year, month 뿐만 아니라 rawData , setUserData 를 전달해야 하기 때문이다. 이 때 rawData를 전달하는 이유는, 새로운 카드를 생성할 때 그 때의 시점에서 rawData에 데이터를 추가해야 하기 때문이다. 또한, 해당 카드를 사용하기 위해 setUserData를 전달하여 시점을 해당 카드로 이동한다.

NewCard

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const NewCard = ({ year, month, rawData, setUserData }) => {
const createCard = async () => {
const cardForm = {
date: getDate(),
title: "",
image: "",
weather: "",
tags: [],
summary: "",
text: ""
};
rawData[year][month].push(cardForm);
const data = await createCardData(rawData);
data[year] && setUserData(data[year][month]);
};

위에서 내려준 데이터를 받아 카드를 새로 생성하는 컴포넌트이다. createCard는 데이터를 받아 세팅해야 하니 async 로 설정하고, 기본적인 cardForm 을 만들어 이를 rawData의 year, month에 설정함으로써 새로운 데이터를 push한다. 이후 createCardData api를 통해 post하여 data를 생성 (원래는 수정) 한다. 정상적으로 작동할 경우 data를 반환해야 하니 해당 데이터가 반환되는 것이 확인되면, setUserData 를 통해 데이터를 세팅한다.

1
2
3
<div className="card" onClick={createCard}>
<div className="card__text">+ 추가해 주세요</div>
</div>

카드를 생성하기 위해서, 클릭했을 때 위에서 정의한 createCard 를 onClick에 대한 함수로 설정한다. 이 때, tag 종류에 상관 없이 onClick이 정의 가능하다! (button, div 등)

Card

1
<div className="card__title">{title ? title : "제목 없음"}</div>

기존의 card__title 형식을 {title}에서 조건을 추가하여, title이 없을 경우 “제목 없음”을 렌더링하도록 설정한다.


7️⃣

카드 클릭시 Diary 뷰로 이동하기

App.js

1
2
3
4
5
6
7
8
<Route
path="/diary/:id"
component={() => <Diary year={year} month={month} />}
/>
<Route
path="/diary/edit/:id"
component={() => <Diary year={year} month={month} />}
/>

기존에는 component에 컴포넌트만 전달했다면, 이제는 year, month를 props로 전달한다.

diary/Card.js

그리고, diary page에서 보이는 card 컴포넌트를 정의한다. diary에서는 카드를 읽고/ 수정할 수 있기 때문에, 이를 url 매칭 여부에 따라 조절한다.

1
2
3
const Card = ({ userData, match }) => {
const isReadOnly = match.path === "/diary/:id" ? true : false;
const { title, date, image, weather, tags, summary, text } = userData;

받아온 userData를 구조분해할당하여 일단 다음과 같이 렌더링 하고,

1
2
3
4
5
6
7
8
9
10
11
return (
<CardWrap>
<p>{title}</p>
<p>{date}</p>
<img src={image} width="200" alt="" />
<p>{weather}</p>
<p>{tags}</p>
<p>{summary}</p>
<p>{text}</p>
</CardWrap>
);

match를 이용하기 위해 withRouter를 사용한다.

1
export default withRouter(Card);

Main.js

history 사용을 위해 withRouter를 설정한다.

1
2
3
4
5
6
7
import { withRouter } from "react-router-dom";

const Main = ({ year, month, history }) => {
...
}

export default withRouter(Main);

카드를 렌더링한 후 개별적으로 선택할 때마다, history에 각각이 가지고 있는 id 를 포함한 url을 push하여 카드가 렌더링되도록 한다. 이를 위해 Card 컴포넌트에 onClickFunc 을 전달한다. 여기서 이 function을 정의하고 전달하는 이유는, 카드 목록에서 선택한 카드를 렌더링하기 위해서는 목록에서 선택한 후 해당 url로 이동해야 하기 때문이다.

1
2
3
4
5
<Card
key={index}
userData={data}
onClickFunc={() => history.push(`/diary/${data.id}`)}
/>

카드 목록 끝에는 새 카드를 생성하는 NewCard 컴포넌트가 필요하다. 이 때, 생성하는 시점의 year, month, 해당 카드를 추가하기 위한 rawData, 설정하기 위한 setUserData, 그리고 id를 생성해 props로 설정한다.

1
2
3
4
5
6
7
<NewCard
year={year}
month={month}
rawData={rawData}
setUserData={setUserData}
id={userData ? userData.length + 1 : 1}
/>

main/Card.js

onClickFunc 을 전달하여 onClick 에 대한 함수로 설정한다.

1
2
3
4
const Card = ({ userData, onClickFunc }) => {
return (
<CardWrap>
<div className="card" onClick={onClickFunc}>

NewCard.js

위에서 전달한 id 를 받아 cardForm의 프로퍼티로 추가한다.

1
2
3
4
5
6
7
8
9
10
11
const NewCard = ({ id, year, month, rawData, setUserData }) => {
const cardForm = {
date: getDate(),
id: id,
title: "",
image: "",
weather: "",
tags: [],
summary: "",
text: ""
};

Diary.js

withRouter를 설정하여 match를 사용할 수 있도록 한다음, match에서 전달받은 (현재 선택한) 카드의 id를 받아온다. 이후 api를 사용하여 rawData를 불러와 find 를 통해 해당 id와 일치하는 데이터를 가져와 지금의 diaryData로 세팅한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Diary = ({ year, month, match }) => {
const id = match.params.id;
const [diaryData, setDiaryData] = useState(null);

useEffect(() => {
(async () => {
const data = await getCardData();
data[year] && setDiaryData(data[year][month].find((el) => el.id === id));
})();
}, [id]);

...
}

export default withRouter(Diary);

앞에서 설정한 diaryData 를 가지고 Card 컴포넌트를 렌더링한다. 계속 사용하고 있었지만, api를 사용해서 데이터를 불러와 세팅하는 과정을 useEffect로 포함한다면 이를 올바르게 렌더링하기 위해 (null 값이 뜨는 버그가 발생하지 않게…) 데이터가 존재할 때만 렌더링되도록 && 조건을 설정해야 한다!

1
return diaryData && <Card diaryData={diaryData} />;

8️⃣

Diary 뷰 Header 구현하기

diary/Card.js

앞서 정의했던 isReadOnlyCardHeader 컴포넌트에 전달하여 수정 상태 여부에 따라 다르게 렌더링 될 수 있도록 한다.

1
<CardHeader title={title} isReadOnly={isReadOnly} />

diary/CardHeader.js

title 의 경우 직접 작성하는 것이므로 input 으로 설정해 수정되는 값을 전달하고 받을 수 있도록 설정한다. 특히 input에서 주의할 점은 value를 설정해야 한다는 것! value를 설정하지 않으면 input이 적절하게 작동하지 않는다..

1
2
3
4
5
6
7
8
9
10
const CardHeader = ({ title, isReadOnly }) => {
return (
<CardHeaderWrap>
<input
type="text"
className="header__title"
placeholder="제목을 입력해 주세요"
value={title}
readOnly={isReadOnly}
/>

이 외에도 수정, 삭제를 위해 버튼을 생성한다.

1
2
<button className="header__edit">수정</button>
<button className="header__delete">삭제</button>

9️⃣

Diary 상세기능 뷰 구현하기

Card.js : Card의 모든 내용을 포함하고 있는, 카드 전체

구조분해할당으로 일일히 가져오던 데이터를 state 에 저장하여 사용하도록 한다. 이유는 onChange에 따른 변화를 저장하기 위함이다.

1
2
3
4
5
6
7
8
9
10
const [state, setState] = useState(userData);

const handleChange = (e) => {
const name = e.target.name;

setState({
...state,
[name]: e.target.value
});
};

이 때 setState에 기본적으로 userData 를 넣어놨기 때문에, 수정된 부분만 반영하기 위해서 ... 로 기존의 정보를 풀고, [변수] 을 이용하여 해당 변수 가 담고 있는 값을 가진 프로퍼티를 수정한다.

해당 handleChange 를 적용하기 위해, 각 부분에 이를 전달한다. 이 때, 각 컴포넌트는 state 로 전달하는 값을 변경하고 저장한다. 또한, 읽기모드에서는 수정이 불가하게 하기 위해 isReadOnly 를 통해 이를 관리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<CardHeader
title={state.title}
handleChange={handleChange}
isReadOnly={isReadOnly}
/>
<CardInfo
userData={state}
isReadOnly={isReadOnly}
handleChange={handleChange}
/>
<textarea
placeholder="오늘을 기록해 주세요"
isReadOnly={isReadOnly}
value={state.text}
name="text"
onChange={handleChange}
/>

CardInfo.js : Card의 상세정보를 담고 있는 컴포넌트

1
2
3
4
import FormControl from "@material-ui/core/FormControl";
import NativeSelect from "@material-ui/core/NativeSelect";
import { makeStyles, withStyles } from "@material-ui/core/styles";
import InputBase from "@material-ui/core/InputBase";

위의 라이브러리는 모두 materialUI 를 사용하기 위해 import 한 것이다.

스타일을 사용한 부분은 날씨 select 파트로, 옵션에 스타일을 주기 위해 정의하였다. materialUI에서 선택한 테마를 사용하기 위해 정의한 것들은 다음과 같다.

  1. makeStyles

    1
    2
    3
    4
    5
    6
    7
    const useStyles = makeStyles({
    select: {
    "& .MuiSvgIcon-root": {
    display: "none"
    }
    }
    });
    1
    const classes = useStyles();
    1
    2
    3
    4
    <NativeSelect
    className={classes.select}
    ...
    >
  2. withStyles

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const BootstrapInput = withStyles((theme) => ({
    input: {
    borderRadius: 5,
    position: "relative",
    backgroundColor: "white",
    border: "1px solid #CEA0E3",
    fontSize: 18,
    padding: "5px 7px",
    transition: theme.transitions.create(["border-color", "box-shadow"]),
    background: `url(${Select}) no-repeat 95% 50%`,
    "&:focus": {
    borderRadius: 5,
    borderColor: "#CEA0E3",
    backgroundColor: "white",
    boxShadow: "0 0 0 0.2rem rgba(206,160,227,.25)"
    }
    }
    }))(InputBase);
    1
    2
    3
    4
    <NativeSelect
    ...
    input={<BootstrapInput />}
    >

    위와 같은 스타일이 적용된 전체적인 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<span>날씨</span>
{isReadOnly ? (
<input
type="text"
isReadOnly={isReadOnly}
value={weather}
placeholder="날씨를 선택해주세요"
/>
) : (
<FormControl>
<NativeSelect
className={classes.select}
value={weather}
name="weather"
onChange={handleChange}
input={<BootstrapInput />}
>
<option value="" disabled>
날씨를 선택해주세요
</option>
<option value={"맑음"}>맑음</option>
<option value={"구름"}>구름</option>
<option value={"흐림"}>흐림</option>
<option value={"비"}>비</option>
<option value={"눈"}>눈</option>
<option value={"바람"}>바람</option>
</NativeSelect>
</FormControl>
)}

태그의 경우, 태그 배열이 비어있지 않은 경우, 즉 길이가 0보다 큰 경우에만 map 으로 배열을 순회하도록 하였다.

1
2
3
4
<div className="info__tags">
<span>태그</span>
{tags.length > 0 ? (
tags.map((tag, index) => {

만약 배열이 비어있다면, input 필드로 락을 걸어놓는다.

1
2
3
4
5
6
<input
type="text"
isReadOnly={true}
value=""
placeholder="태그를 선택해주세요"
/>

그 외는 textarea 로 내용을 담는다.

1
2
3
4
5
6
7
<textarea
placeholder="오늘을 기록해 주세요"
isReadOnly={isReadOnly}
value={state.text}
name="text"
onChange={handleChange}
/>

🔟

카드 수정 기능 구현

diary/Card.js

CardHeader에서 수정 기능을 이용할 수 있도록, 이를 포함하고 있는 diary 페이지의 Main 최상단 컴포넌트 Card에서 handleEdit 을 정의하고 넘겨준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const id = parseInt(match.params.id);  

const handleEdit = async () => {
// const index = rawData[year][month].findIndex((data) => data.id === id);

// newList를 따로 만드는 이유? rawData가 state의 readOnly 값이기 때문에 newList로 값을 복사해서 사용합니다
const newList = rawData[year].filter((data) => data); // 새로운 배열 반환
newList[month][id] = state;
const data = await createCardData(rawData);
history.goBack();
};

export default withRouter(Card);

rawData 자체는 읽기 전용이기 때문에, 이를 준수하기 위해 filter 함수를 이용해 새로운 리스트인 newList 를 생성하고 데이터를 담는다. 여기서 id에 맞게 state를 저장한 후, 새롭게 변경된 rawData는 POST api를 통해 (PUT 대신) createCardData로 업로드 해준다. 마지막으로, 수정이 완료되었을 때 완료 버튼을 클릭하여 해당 과정을 진행하는 것이므로, 이를 끝마친 후 확인 상태로 돌아가기 위해 history.goBack() 을 통해 뷰 단계로 돌아간다.

diary/CardHeader.js

Card에서 내려받은 handleEdit 을 이용하여 직접 수정하는 부분은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const CardHeader = ({
title,
isReadOnly,
handleChange,
handleEdit,
match,
history
}) => {
const id = match.params.id;
.
.
.

{isReadOnly ? (
<button
className="header__edit"
onClick={() => history.push(`/diary/edit/${id}`)}
>
수정
</button>
) : (
<button className="header__edit" onClick={handleEdit}>
완료
</button>
)}
  1. 수정 버튼을 클릭하면 history.push 를 통해 현재 id 의 edit url로 이동한다.
  2. 완료 버튼을 클릭하면 전달받은 handleEdit 을 실행시켜 변경된 데이터를 저장하고 메인 뷰로 돌아간다.

pages/Diary.js

먼저, year에 맞는 data가 존재한다면 (rough하게 year만 찾기) setDiaryData를 이용해 현재 match의 id 값과 일치하는 데이터를 month 배열에서 찾아 저장한다. 또한, rawData 자체도 저장하여 Card에 이를 전달하여 수정/이용할 수 있도록 한다.

마찬가지로, null 에러가 뜨지 않기 위해 diaryData && 를 통해 데이터가 존재할 때만 Card 컴포넌트를 렌더링 하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const id = match.params.id;

useEffect(() => {
(async () => {
const data = await getCardData();
data[year] && setDiaryData(data[year][month].find((el) => el.id === id));
setRawData(data);
})();
}, [id]);

return (
diaryData && (
<Card userData={diaryData} rawData={rawData} year={year} month={month} />
)
);

카드 삭제 기능 구현

diary/Card.js

삭제 기능은 위의 handleEdit 과 비슷하게, newList를 만들어 그에 변경된 데이터를 집어넣고 POST api를 통해 업로드한 뒤 전 url로 돌아간다.

여기서 다른 점은, filter 함수를 통해 삭제하고자 하는 현 카드 외의 데이터만 남겨 filteredList 에 반환해야 한다는 점이다.

1
2
3
4
5
6
7
const handleDelete = async () => {
const filteredList = rawData[year][month].filter((data) => data.id !== id);
const newList = rawData[year].filter((data) => data);
newList[month] = filteredList;
const data = await createCardData(rawData);
history.goBack();
};

이를 CardHeader에 전달하고 CardHeader은 이를 전달받아 onClick 함수로 지정하는 모든 과정은 수정과 동일하다.