개인프로젝트: Todo 리스트 만들기(1)

윤뿔소·2022년 11월 15일
0

프로젝트

목록 보기
3/4

솔로 프로젝트로 투두리스트를 만들 것이다. 나 혼자이기에 일단 구현부터 들어가겠다.
(1)에는 기본적인 구조 및 CRUD를 구성할 것이고, 캘린더나 날짜 데이터별 CRUD 등은 조금씩 기능 추가하는 식으로 전개하겠다. 웹 앱 형식으로 모바일부터 반응형도 추가하는 식으로 디자인해보겠다.

여기서는 피그마 + 리액트로 간단한 Todo 리스트를 만들어봤다!

기획 및 디자인

기획

당연히 create-react-app 모듈을 사용해 만들고 시작한다.
npx create-react-app 폴더이름

사용 라이브러리

CSS 툴: Styled-Components
상태관리: Redux toolkit(아니면 recoil? 근데 작은 규모라면 안써도 뭐) 도전!
기타: json-server, axios

컴포넌트 기획

  1. 날짜가 있는 헤더: React-calendar 라이브러리를 사용
  2. 리스트로 펼쳐지는 본 섹션: 체크박스, 글자를 클릭하면 수정되게, 마우스가 올라가면 삭제 버튼 구현
  3. 습관 / 생성버튼 / 일일 과제 페이지 3개로 나뉘어 펼침: 메인으로 습관이 나오도록 하고, 생성버튼은 가볍게 +로, 일일과제는 날짜가 지나면 그 전 거는 저장되고 초기화되게(이거 어려울 것 같음..)

데이터 불러오고 저장하기

서버를 만들어야하나 static.js 를 따로 둬서 localstorage를 통해 저장되야하나 싶다.. 이거는 구현하고 고민해봐야될 문제.

디자인

피그마로 기획했다. 기본적으로 핸드폰 화면부터 디자인했다. 하지만 반응형을 배웠으니 미디어 쿼리를 이용한 반응형 웹 앱으로 나중에 다시 디자인하여 기획해보자

개발

CDD 방식으로 컴포넌트부터 차근차근 올리면서 개발하는 방법을 택했다.

서버

서버 제작

먼저 CRUD 작업을 위한 json-server로 일단 임시 서버를 만들자!

npm i -g json-server
json-server --watch 이름.json --port 3001

fake-server/listData.json

{
  "habitTodos": [
    {
      "id": "7b567643-918d-4a67-866a-2377f257485e",
      "text": "먹기",
      "done": false
    },
    {
      "id": "f4e033f5-4249-4931-a8b1-d52092406daf",
      "text": "씻기",
      "done": true
    },
    {
      "id": "fee8d5b2-e7a5-4638-a82e-363b593c63d1",
      "text": "공부하기",
      "done": false
    },
    {
      "id": "5dfa1223-1bf9-47a9-9124-357cea38a0df",
      "text": "⭐️게임하기",
      "done": false
    }
  ],
  "dailyTodos": [
    {
      "id": "5731047f-f256-4a20-b744-6599559eec7d",
      "text": "무신사에서 옷사기",
      "done": false
    },
    {
      "id": "e5cd6c87-2f97-4d8a-93e1-abe8e407e0ff",
      "text": "Todo List 만들기",
      "done": false
    }
  ]
}

이제 이렇게 만들었으니 params로 habitTodos, dailyTodos로 되고, 각 id 별로 불러올 수 있다!

API 작성하기 및 useFetch 만들기

API 작성하기

처음엔 주석처럼 작성했다가 가시성을 이유로 리팩토링 + 비동기로 처리했음

import axios from "axios";

// export const listCreate = (url, data) => {
//   axios
//     .post(url, {
//       data: JSON.stringify(data),
//     })
//     .catch((error) => {
//       console.error("Error", error);
//     });
// };

export const listCreate = async (url, data) => {
  try {
    await axios.post(url, {
      data: JSON.stringify(data),
    });
  } catch (error) {
    console.error("Error", error);
  }
};

// Read는 useFetch로

export const listUpdate = async (url, id, data) => {
  try {
    await axios.put(`${url}/${id}`, { data: JSON.stringify(data) });
  } catch (error) {
    console.error("Error", error);
  }
};

export const listDelete = async (url, id) => {
  try {
    await axios.delete(`${url}/${id}`);
  } catch (error) {
    console.error("Error", error);
  }
};

useFetch 만들기

get은 useFetch로 커스텀하여 만들어본다. 변할 때마다 리렌더링 해주기 위해서!

import { useState, useEffect } from "react";
import axios from "axios";

function useFetch(url) {
  //null설정한 이유: 모든 data가 같진 않기 때문
  const [list, setList] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    axios
      .get(url)
      .then((response) => {
        setList(response.data);
      })
      .catch((err) => {
        setError(err);
      })
      .finally(() => {
        setLoading(false);
      });
  }, [url]);

  return { list, loading, error };
}

export default useFetch;

컴포넌트

먼저 컴포넌트를 작성하자! CSS는 Styled-Component로 !

일단 헤더에 오늘 날짜를 나오게 만들었다. 초기 기획했던 것과는 달리 시간 상 날짜 별로 골라 투두리스트를 만들기 어려웠다. 나중에 프로젝트 진행하며 기능 추가하는 식으로 전개하겠다.

import React, { useEffect, useState } from "react";
import Calendar from "react-calendar";
import moment from "moment";
import "./Calendar.css"; // css import
import styled from "styled-components";

const HeaderContainer = styled.div`
  display: flex;
  justify-content: center;
  flex-direction: column;
  position: relative;
  cursor: pointer;
  border-bottom: 1px solid #0f4c75;
  height: 96px;
`;
const DayToYear = styled.div`
  font-size: 30px;
  margin: auto;
`;
const DayOfWeek = styled.div`
  font-size: 25px;
  margin: auto auto 13px;
  color: #bbe1fa;
`;

const Header = () => {
  // 캘린더 상태값
  const [value, onChange] = useState(new Date());
  // TODO: 날짜 데이터를 이런 형식으로 넘기기 moment().format("YYYY/MM/DD");

  return (
    <>
      <HeaderContainer onClick={modalHandler}>
        <DayToYear>{moment(value).format("YYYY년 MM월 DD일")}</DayToYear>
        <DayOfWeek>{moment(value).format("dddd")}</DayOfWeek>
      </HeaderContainer>
    </>
  );
};

export default Header;

리스트 표현

List.js

리스트를 담을 그릇인 List와 Lists 컴포넌트를 만들어 보자!
아이콘 받을 모듈인 material-ui를 설치하고 받을 건 import 해오자.

npm i @mui/material @emotion/react @emotion/styled
import DeleteIcon from "@material-ui/icons/Delete";
import DoneIcon from "@material-ui/icons/Done";

Lists.js

List를 모아 map을 해줘 뿌린다!

import React from "react";
import styled from "styled-components";
import List from "./List";

const TodoListBlock = styled.div`
  flex: 1;
  padding: 20px 32px;
  padding-bottom: 48px;
  overflow-y: auto;
`;

const StyledText = styled.div`
  margin-top: 70px;
  text-align: center;
  font-weight: 700;
`;

const Lists = ({ listData = [], handleItemToggle, handleItemRemove }) => {
  return (
    // 얘는 리스트 페이지마다 놔줘야하나?
    <TodoListBlock>
      {listData.length ? (
        listData.map((list) => (
          <List
            key={list.id}
            id={list.id}
            text={list.text}
            done={list.done}
            handleToggle={handleItemToggle}
            handleRemove={handleItemRemove}
          />
        ))
      ) : (
        <StyledText>아직 할일이 없어요.</StyledText>
      )}
    </TodoListBlock>
  );
};

export default Lists;

Daily.js / Habit.js

여기서 이제 재료, API를 연결해 CRUD하고, props로 상태들을 공유해줄 것이다!

import React, { useEffect } from "react";
import Lists from "../components/Lists";
import useFetch from "../util/useFetch";
import Loading from "../components/Loading";

const Habit = () => {
  
  // side effect 때문에 listData가 구성될때 한번 리렌더링되게 하기!
  useEffect(() => {}, [list]);

  return (
    <>
      <Lists />
    </>
  );
};

export default Habit;

이제 Habit으로 만들었으니 Daily.js도 치장할 차례다. list의 구성, CSS는 이미 만들어 놨으니 Habit에서 복사해 url만 고쳐주자! 간편해!

const url = "http://localhost:3001/dailyTodos";

Footer.js

이제 막바지, Footer다.

기획으로는 border는 없고 중간에 create, 습관-일일 과제로 라우팅 하면 된다. 일단 Footer의 구성을 구축하고 라우팅하고 버튼 기능을 구현하겠다.

기본 구조

// App.js
function App() {
  return (
    <BrowserRouter>
      <GlobalStyle />
      <div className="App">
        <Header />
        <Routes>
          <Route exact path="/" element={<Habit />} />
          <Route exact path="/daily" element={<Daily />} />
        </Routes>
      </div>
    </BrowserRouter>
  );
}
// Footer.js
import React, { useState } from "react";
import styled, { css } from "styled-components";
import { Link } from "react-router-dom";

const FooterContainer = styled.div`
  display: flex;
  height: 96px;
  position: absolute;
  bottom: 0;
  width: 100%;
`;

const HabitButton = styled.div`
  cursor: pointer;
  font-size: 25px;
  display: flex;
  flex: 1;
  justify-content: center;
  padding: 20px;

  ${(props) =>
    props.curruntPage &&
    css`
      color: #bbe1fa;
    `}
`;
const CreateButtonContainer = styled.div`
  cursor: pointer;
  background-color: #3282b8;
  border-radius: 10px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
  width: 89px;
  height: 89px;

  &:hover {
    box-shadow: 10px 14px 28px rgba(0, 0, 0, 0.25), 10px 10px 10px rgba(0, 0, 0, 0.22);
  }
`;
const CreateButton = styled.div`
  font-size: 50px;
  width: 100%;
  height: 100%;
  color: #bbe1fa;
  text-align: center;
`;
const DailyButton = styled.div`
  cursor: pointer;
  font-size: 25px;
  display: flex;
  flex: 1;
  justify-content: center;
  padding: 20px;

  ${(props) =>
    props.curruntPage &&
    css`
      color: #bbe1fa;
    `}
`;
const LinkConainer = styled(Link)`
  text-decoration: none;
  color: white;
`;

const Footer = ({ curruntPage, handleItemCreate }) => {
  return (
    <>
      <FooterContainer>
        <HabitButton curruntPage={curruntPage}>
          <LinkConainer to={`/`}>습관</LinkConainer>
        </HabitButton>
        <CreateButtonContainer>
          <CreateButton onClick={modalOpenHandler}>+</CreateButton>
        </CreateButtonContainer>
        <DailyButton curruntPage={curruntPage}>
          <LinkConainer to={`/daily`}>일일 과제</LinkConainer>
        </DailyButton>
      </FooterContainer>
    </>
  );
};

export default Footer;

리스트 속 CRUD

Read: useFetch 적용

useFetch 가져와서 url을 집어넣어 데이터를 읽어와보자!

// habit.js
import React from "react";
import Lists from "../components/Lists";
import useFetch from "../util/useFetch";
import Loading from "../components/Loading";

const Habit = ({ habitLists }) => {
  const { list } = useFetch("http://localhost:3001/habitTodos");
  console.log(list);

  return (
    <>
      <Lists listData={list} />
    </>
  );
};

export default Habit;

겪은 문제

계속 null이 떠서 데이터를 못가져오는 불상사가 일어났었다.
Uncaught TypeError: Cannot read properties of undefined (reading 'TodoItemBlock')
왜냐면 위 코드를 보면 바로 list 데이터를 뿌려줬는데, fetch의 속도와 렌더링된 속도가 달라 null을 건네주기에 리액트에선 데이터를 읽지 못한다. 당연하다.. 그 당연한 걸 내가 생각 못했을 뿐이지.. 그래서

{list ? <Lists listData={list} /> : <Loading />}

이렇게 list가 오면 데이터가 나오고, 아니면 Loading이 나오게 해 오류가 나지 않으면서도 UX를 올렸다. 이거 은근 어려웠다.. 1시간을 잡아먹게 했다.

Delete: 삭제 버튼 구현

여기도 많이 해맸다. id를 추출하지 못해 복습을 좀 했고 깨달은 것이 '이벤트를 바로 할당하는 것이 아닌 실행값을 할당해야한다'라는 걸 깨달았다.
List.js을 위해

// Habit.js
const Habit = () => {
  const { list } = useFetch("http://localhost:3001/habitTodos");
  const handleItemRemove = (id) => {
    listDelete("http://localhost:3001/habitTodos", id);
  };

  return <>{list ? <Lists listData={list} handleItemRemove={handleItemRemove} /> : <Loading />}</>;
};

// Lists.js
const Lists = ({ listData = [], handleItemToggle, handleItemRemove }) => {
  // side effect 때문에 listData가 구성될때 한번 리렌더링되게 하기!
  useEffect(() => {}, [listData]);

  return (
    // 얘는 리스트 페이지마다 놔줘야하나?
    <TodoListBlock>
      {listData.length ? (
        listData.map((list) => (
          <List
            key={list.id}
            id={list.id}
            text={list.text}
            done={list.done}
            handleToggle={handleItemToggle}
            handleRemove={handleItemRemove}
          />
        ))
      ) : (
        <StyledText>아직 할일이 없어요.</StyledText>
      )}
    </TodoListBlock>
  );
};
// List.js
const List = ({ id, text, done, handleToggle, handleRemove }) => {
  const onRemove = () => {
    handleRemove(id);
  };

  return (
    <>
      <TodoItemBlock>
        <CheckCircle></CheckCircle>
        <Text>{text}</Text>
        <Remove onClick={onRemove} />
      </TodoItemBlock>
    </>
  );
};

문제 발생

json-server라 그런지 핫로딩이 안돼 삭제버튼을 눌러도 바로 반영이 안된다. 결국 api window.location.reload();에 이걸 넣었는데 이러면 솔직히 CRA의 장점인 '새로고침이 없음'이 퇴색돼버린다. 이걸 해결하려면 node로 직접 만들어야하나.. next나 express로 나중에 서버를 제대로 구축해봐야겠다.

Update: 수정 구현

글 자체를 클릭하면 수정되게끔 구현해보자!

import React, { useState } from "react";
import styled, { css } from "styled-components";
import { AiOutlineDelete } from "react-icons/ai";
import { AiOutlineCheck } from "react-icons/ai";

...

const Input = styled.input`
  width: 100%;
  height: 40px;
  padding: 10px;
  font-size: 20px;
  color: #333;
  border: 1px solid #ccc;
  /* border-radius: 4px; */

  &:focus {
    outline: none;
    border-color: #333;
  }
`;

const ModalContainer = styled.div`
  margin: auto;
  position: relative;
  z-index: 2;
`;
const ModalBackdrop = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background: rgba(0, 0, 0, 0.5);
`;
const ModalView = styled.div.attrs((props) => ({
  role: "dialog",
}))`
  position: absolute;
  top: calc(50vh - 100px);
  left: calc(50vw - 200px);
  background-color: white;
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 10px;
  width: 400px;
  height: 150px;
`;
const ModalCloseBtn = styled.button`
  background-color: #fff;
  color: #000;
  text-decoration: none;
  border: none;
  position: absolute;
  top: 10%;
  cursor: pointer;
  font-size: 20px;
`;

const List = ({ id, text, done, handleToggle, handleRevise, handleRemove }) => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [revisedText, setRevisedText] = useState(text);

  const modalOpenHandler = (e) => {
    setIsModalOpen(true);
  };
  const modalCloseHandler = () => {
    setIsModalOpen(false);
  };

  const onChange = (e) => {
    setRevisedText(e.target.value);
  };
  const onRevise = () => {
    handleRevise(id, revisedText);
    modalCloseHandler();
  };
  const onKeyPress = (e) => {
    if (e.key === "Enter") {
      onRevise();
    }
  };

  const onRemove = () => {
    handleRemove(id);
  };

  return (
    <>
      <TodoItemBlock>
        <CheckCircle></CheckCircle>
        <Text onClick={modalOpenHandler}>{text}</Text>
        <Remove onClick={onRemove} />
      </TodoItemBlock>
      <ModalContainer>
        {isModalOpen && (
          <ModalBackdrop>
            <ModalView>
              <ModalCloseBtn onClick={modalCloseHandler}>x</ModalCloseBtn>
              <Input type="text" value={revisedText} onChange={onChange} onKeyPress={onKeyPress}></Input>
            </ModalView>
          </ModalBackdrop>
        )}
      </ModalContainer>
    </>
  );
};

export default List;

글을 클릭하면 인풋 창이 나오게 했다.

그리고 Lists, Habit 각각 handleItemToggle로 props 내려줬다.

// Lists.js
const Lists = ({ listData = [], handleItemToggle, handleItemRevise, handleItemRemove }) => {
  return (
    <TodoListBlock>
      {listData.length ? (
        listData.map((list) => (
          <List
            key={list.id}
            id={list.id}
            text={list.text}
            done={list.done}
            handleToggle={handleItemToggle}
            handleRevise={handleItemRevise}
            handleRemove={handleItemRemove}
          />
        ))
      ) : (
        <StyledText>아직 할일이 없어요.</StyledText>
      )}
    </TodoListBlock>
  );
};
// Habit.js
  return (
    <>
      {!loading && list ? (
        <Lists listData={list} handleItemRevise={handleItemRevise} handleItemRemove={handleItemRemove} />
      ) : (
        <Loading />
      )}
    </>
  );

문제 충돌.. 어째 한번 빠지지가 않냐..

Update로 넘겨주면 "\"공부하자!\"",로 데이터가 넘어가는 문제가 있었다.

이걸 어떻게 해결해야하나.. 모르겠다..

Update: 체크 토글 구현

투두리스트 핵심! 체크 버전을 누르면 글이 투명해지고 체크가 생긴다.
styled-component의 props 기능으로 props가 해당되면 css가 적용케 했다.

import React, { useState } from "react";
import styled, { css } from "styled-components";
import { AiOutlineDelete } from "react-icons/ai";
import { AiOutlineCheck } from "react-icons/ai";

const CheckCircle = styled(AiOutlineCheck)`
  color: #1b262c;
  width: 20px;
  height: 20px;
  border-radius: 10px;
  border: 1px solid #ced4da;
  font-size: 13px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 10px;
  cursor: pointer;

  ${(props) =>
    props.done &&
    css`
      border: 1px solid #bbe1fa;
      color: #bbe1fa;
    `}
`;

const Text = styled.div`
  font-size: 20px;
  cursor: pointer;

  ${(props) =>
    props.done &&
    css`
      text-decoration: line-through;
      color: rgba(187, 225, 250, 0.5);
    `}
`;

...

const List = ({ id, text, done, handleToggle, handleRevise, handleRemove }) => {

  ...

  const onToggle = () => {
    handleToggle(id, !done);
  };
  const onRemove = () => {
    handleRemove(id);
  };

  return (
    <>
      <TodoItemBlock>
        <CheckCircle done={done} onClick={onToggle}></CheckCircle>
        <Text onClick={modalOpenHandler} done={done}>
          {text}
        </Text>
        <Remove onClick={onRemove} />
      </TodoItemBlock>
      <ModalContainer>
        {isModalOpen && (
          <ModalBackdrop>
            <ModalView>
              <ModalCloseBtn onClick={modalCloseHandler}>x</ModalCloseBtn>
              <Input type="text" value={revisedText} onChange={onChange} onKeyPress={onKeyPress}></Input>
            </ModalView>
          </ModalBackdrop>
        )}
      </ModalContainer>
    </>
  );
};

export default List;

CheckCircle, Text에 props done을 입력해 서버에서 가져온 데이터를 받아주면서 CSS도 적용하게 됐다. 그리고 onToggle!를 적용해 기존 값의 반대 값을 api로 전송하게 했다. 이건 쉽네!

Create: Footer.js

이제 막바지, Footer의 추가 버튼 구현이다!.

Footer의 구성을 구축하고 버튼 기능을 구현하겠다.

import React, { useState } from "react";
import styled, { css } from "styled-components";
import { Link } from "react-router-dom";

...

const ModalContainer = styled.div`
  margin: auto;
  position: relative;
  z-index: 2;
`;
const ModalBackdrop = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background: rgba(0, 0, 0, 0.5);
`;
const ModalView = styled.div.attrs(() => ({
  role: "dialog",
}))`
  position: absolute;
  top: calc(50vh - 100px);
  left: calc(50vw - 185px);
  background-color: #1b262c;
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 10px;
  width: 400px;
  height: 150px;
`;
const ModalCloseBtn = styled.button`
  background-color: #1b262c;
  color: #eaeaea;
  text-decoration: none;
  border: none;
  position: absolute;
  top: 10%;
  cursor: pointer;
  font-size: 20px;
`;

const Input = styled.input`
  width: 100%;
  height: 40px;
  padding: 10px;
  font-size: 20px;
  color: #333;
  border: 1px solid #0f4c75;
  background-color: #1b262c;
  color: #eaeaea;

  &:focus {
    outline: none;
    border-color: #0f4c75;
  }
`;

const Footer = ({ curruntPage, handleItemCreate }) => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [text, setText] = useState("");
  const modalOpenHandler = (e) => {
    setIsModalOpen(true);
  };
  const modalCloseHandler = () => {
    setIsModalOpen(false);
  };

  const onChange = (e) => {
    setText(e.target.value);
  };
  const onCreate = () => {
    handleItemCreate(text);
    modalCloseHandler();
  };
  const onKeyPress = (e) => {
    if (e.key === "Enter") {
      onCreate();
      setText("");
    }
  };

  return (
    <>
      <FooterContainer>
        ...
      </FooterContainer>
      <ModalContainer>
        {isModalOpen && (
          <ModalBackdrop>
            <ModalView>
              <ModalCloseBtn onClick={modalCloseHandler}>x</ModalCloseBtn>
              <Input type="text" value={text} onChange={onChange} onKeyPress={onKeyPress}></Input>
            </ModalView>
          </ModalBackdrop>
        )}
      </ModalContainer>
    </>
  );
};

export default Footer;

List에서 썼던 모달을 재사용해 제출을 하려고 한다.(재사용을 했으니 컴포넌트로 따로 커스텀할까?)

profile
코뿔소처럼 저돌적으로

0개의 댓글