[#8] React로 Task Manager 만들기

오닐·2022년 6월 14일
2

React : Task Manager

목록 보기
8/11

🧙‍♀️1. WishList 기능 완성

Wish는 Diary만큼 길게 쓸 만한 내용이 없기 때문에 따로 작성, 수정, 상세 페이지를 만들지 않고 모달창으로 다 해결하려고 했다. 모달 컴포넌트를 미리 만들어 둔 덕분에 작성과 상세창까지는 일사천리였는데... (늘 그렇듯) 문제가 생겼다!

내가 원하는 건 상세 페이지가 모달창으로 띄워진 상태에서 edit 버튼을 누르면 해당 모달창의 내용이 상세 페이지에서 editor로 바뀌는 것이었는데, 이렇게 하려면 editor를 띄우는 함수가 props로 두 번 정도 뚫고 들어가서 로직이 굉장히 복잡해질 것 같았다.

그래서 wish 데이터가 아이템 이름, 가격, 설명 정도밖에 없는 만큼 굳이 상세 페이지까지 만들 필요 없이 WishBox를 누르면 바로 데이터를 수정할 수 있는 editor를 띄워주기로 했다.

다시 말해서 WishBox 클릭 -> 상세 정보 담긴 모달창 띄우기 -> edit 버튼 누르기 -> 수정할 수 있는 페이지로 모달창 내용 변경의 과정을 WishBox 클릭 -> 수정할 수 있는 페이지에 상세 데이터 받아와서 띄우기로 줄였다!

그 결과, WishBox를 누르면 기존의 데이터가 담겨 있는 editor 모달이 뜬다. 이때 단순히 상세 내용을 보려고 할 때와 수정을 하려 할 때를 구분하기 위해 input에 disabled 속성을 걸어주었다. 이 속성을 state로 관리해서 수정을 위해 edit 버튼을 누르면 true였던 속성값이 false로 바뀌어서 input을 사용할 수 있게 해주었다.

전반적으로 Diary 로직과 다르지 않아도 차이점이 있긴 있다.

🧙‍♀️1-1. 수정 페이지에 들어가지 않고도 아이템의 구매 여부 변경

보통 위시리스트는 갖고 싶은 물건을 올려놓은 뒤 그것을 구매하고 나면 해당 구매 여부를 반영할 수 있게끔 만들어져 있다. 갖고 싶은 정도에 따라 Want, Love, Need 등으로 세분화시키기도 하지만, 이건 위시 어플이 아니라 Task Manager의 한 페이지일 뿐이기 때문에 Wish와 Purchased 두 가지로만 구분했다.

새로운 wish를 추가할 때 미리 Wish의 상태로 객체를 생성한 후, WishBox의 GET 버튼을 누르면 이 부분만 Purchased로 바뀌게 만들어 주었다.

const getHandler = (e) => {
    if (window.confirm("Did you get this item?")) {
      dispatch(
        wishActions.wishEdit({
          id,
          date,
          icon: "Purchased",
          name,
          price,
          desc,
        })
      );
    }
e.stopPropagation(); //onClick이 전파돼서 StyledBox의 modalHandler를 실행시키지 않도록
};

코드는 이렇게 생겼고, 원래 WishBox의 다른 부분을 누르면 모달창이 뜨게끔 만들어져 있기 때문에 GET 버튼을 눌렀을 때는 모달창이 뜨지 않도록 stopPropagation으로 이벤트 전파를 막았다. 더불어 Wish와 구분하기 위해 상태가 Purchased로 바뀌면 GET 버튼을 감추고 배경을 회색으로 만드는 스타일도 적용했다.

🧙‍♀️1-2. 세 가지 역할을 하는 Editor

Diary에서도 Editor 컴포넌트가 NewDiary와 EditDiary에서 동일하게 사용되었지만, WishList의 경우는 같은 듯 다르다.

props로 isEdit을 받아서 New와 Edit을 구분하는 것, isEdit이면 기존 데이터를 받아오는 것, New인지 Edit인지에 따라 달라지는 버튼 정도는 같은데, 이번에는 Editor가 상세 페이지도 겸하기 때문에 함수가 추가 되었다.

//input의 disabled를 변경하는 함수
if (isDisabled) {
      if (window.confirm("Do you want to edit your wish?")) {
        setIsDisabled(false);
        return;
      }
}

그리고 어떻게 보면 가장 중요한 작업!
Diary는 페이지를 분리했기 때문에 submit 후 다른 페이지로 이동시키면 됐지만, 이번에는 모달창으로 만들었기 때문에 submit한 다음에 이 모달창을 닫아주어야 한다. 당황하지 말고 부모 컴포넌트에서 modalHandler 함수 props로 받아온 다음에 submitHandler 맨 마지막에 실행시켜주기^^


🧙‍♀️2. 날씨 위젯

OpenWeatherMap은 노마드코더에서 바닐라 자바스크립트로 클론코딩할 때 써 본 API이다. 당시에는 위치랑 날씨만 받아왔었는데, 날씨에 해당하는 아이콘도 받아올 수 있다고 해서 이번에는 데이터를 몇 개 더 받아와서 쓰기로 했다.

우선 useEffect를 사용해서 날씨 위젯이 있는 Home 컴포넌트가 처음 마운트됐을 때 navigator.geolocation.getCurrentPosition(success, error);을 실행시켜서 유저의 현재 위치 정보를 받아왔다. 그리고 이 작업이 성공했을 때 실행할 success 함수를 아래와 같이 만들었다.

  const success = (position) => {
    const lat = position.coords.latitude;
    const lon = position.coords.longitude;
    const apiKey = process.env.REACT_APP_WEATHER_API_KEY;
    const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`;

    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        setCity(data.name);
        setCountry(data.sys.country);
        setIconUrl(
          `http://openweathermap.org/img/wn/${data.weather[0].icon}@4x.png`
        );
        setDegree(Math.floor(data.main.temp));
      });
  };

API key는 .env 파일에 숨겨두었고, 기본적으로 제공되는 API url에 units=metric 속성을 붙여서 기온 등의 단위를 섭씨로 받아왔다.

그다음 해당 url을 fetch해서 데이터를 JSON 형식으로 받은 후, 필요한 정보(도시, 국가, 날씨에 해당하는 아이콘, 기온)를 State에 넣어주었다. 그런 다음 JSX의 원하는 부분에 쏙쏙 집어 넣으면 끝!

그리고 날짜 부분은 API에서 가져올 수가 없어서 따로 Date 객체와 Intl.DateTimeFormat 객체를 이용해서 받아왔다.

const month = new Intl.DateTimeFormat("en-US", { month: "long" }).format(new Date());

const date = new Date().getDate();

const day = new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(new Date());

일자와 달리 월과 요일은 0부터 시작하기 때문에 데이터의 포맷을 바꿔주는 작업을 해주면 국제화된 날짜를 쉽게 받아올 수 있다.


🧙‍♀️3. 시계 위젯

디지털 시계는 만들어 본 적 있으나 했던 걸 또 하기보다는 새로운 걸 배우고 써먹어 보고 싶어서 아날로그 시계를 구현하기로 했다. 참고할 만한 자료를 찾던 중 JavaScript 30 2일차 코드를 알게 되어 무작정 읽어 보았다.

🧙‍♀️3-1. 시계바늘 각도 계산

아날로그 시계의 핵심은 직접 시계바늘의 각도를 계산하는 것이다. 계산식은 쉬우면서도 어려웠다.

우선 초침부터. 60초간 360도를 도니까 1초당 360/60도씩 움직인다. 이를 식으로 표현하면 Seconds * (360/60)가 된다.

다음은 분침이다. 60분간 360도를 도니까 마찬가지로 Mins * (360/60)이지만, 초침이 움직이는 동안 분침도 매 초마다 조금씩 움직이기 때문에 이를 반영해 주어야 한다. 1분, 즉 60초간 6도를 도니까 1초당 6/60도씩. 이를 식으로 표현하면 Seconds * (6/60)이므로 이것까지 더해주면 분침의 각도는 Mins * (360/60) + Seconds * (6/60)가 된다.

시침도 분침과 같은 원리로 계산할 수 있다.

그런데 여기까지 하고 나니 굳이 참고 코드에서처럼 시계바늘을 12시 방향으로 정렬한 상태에서 시작해야 하는지(이거 때문에 계산식 마지막에 90도를 더 더해주어야 원하는 각도가 나온다!) 의문이 들었다. 그리고 결정적으로 어디서 설정을 잘못했는지 transform-origin을 동일하게 설정했는데도 시계바늘들이 같은 곳으로 모이지를 않았다.

계산식은 맞으니 CSS에 문제가 있는 것 같아서 위치 속성을 다 지우고 다시 설정하던 중, 시계바늘을 처음부터 height가 긴 직사각형으로 만든 후 transform-origin을 bottom으로 설정하면 계산식에 90을 더하지 않고도 원하는 각도로 rotate 시킬 수 있다는 점을 알게 되었다!

const Hand = styled.div`
  position: absolute;
  bottom: 50%;

  border-radius: 5px;

  transform-origin: bottom;
`;

const SecondHand = styled(Hand)`
  width: 3px;
  height: 90px;

  background-color: #8b8c89;
`;

예시로 가져온 초침 코드. 이렇게 설정한 후 부모 요소에 relative를 주면 시계바늘이 알아서 부모의 중앙에 모인다!

이렇게 계산한 각도는 state에 저장하고 props로 받아서 CSS에 동적으로 스타일링했다.

🧙‍♀️3-2. CSS

이러한 과정에서 새로운 스타일로 css를 적용해 보기도 했다. 자식 요소에 justify-self: center를 적용하면 부모 요소에 display: flex, justify-content: center를 적용한 것과 비슷한 효과를 낼 수 있다는 거! 무의미한 div를 줄이기 위해 한 번 써봤는데, 유용했다.

추가로, 위에서 만든 날씨 위젯과 시계 위젯을 하나의 div로 묶어서 grid 속성을 주었는데 시계 위젯이 grid 내부에서 중앙으로 정렬되지 못하는 현상이 있었다. flex도 그렇듯 grid에도 분명 중앙 정렬을 할 수 있는 속성이 있을 거라 생각하고 찾아봤고, 그렇게 place-items 속성을 알게 되었다.

Grid의 container에 적용되며, align-items와 justify-items의 단축 속성이라고 한다. 그래서 그런지 center로 설정하면 flexbox에서 align-items: center를 적용한 것과 같은 결과를 얻을 수 있었다. 모든 분야가 그렇지만 CSS야 말로 봐도봐도 새로운 속성이 많은 것 같다.

🧙‍♀️3-3. 안 생기면 서운한 에러

Over 200 classes were generated for component styled.div

매초마다 styled-components가 리렌더링되면서 class가 너무 자주 변경된다는 오류가 발생했다. 시계 바늘이 공통된 속성을 공유한 상태에서 rotate되는 각도와 색상, 길이 등만 다르기 때문에 각도를 저장한 state를 props로 받아서 동적으로 스타일링 해주었더니 매 초마다 state가 바뀌면서 styled-components도 계속 재렌더링 되는 모양이었다.

다행히 에러 메시지 아래에 Styled-components의 attrs 메서드를 이용해서 자주 바뀌는 속성을 인라인 스타일로 구현하라고 친절히 안내가 되어 있어서 해보기로 했으나... 공식 문서에 따르면 다이나믹한 속성도 전달할 수 있다는데 아무리 해봐도 제대로 적용이 안 돼서 그냥 jsx 내부에 <SecondHand style={{ transform: `rotate(${secondDeg}deg)` }} />처럼 직접 인라인 스타일을 적용했다. 방식만 달랐지 attrs 메서드도 결국 이 결과로 가는 길이었을 텐데, 어째서 적용이 안 됐는지는 더 분석을 해봐야 할 것 같다. 어찌됐든 그때그때 컴포넌트를 재생성하지 않고도 아날로그 시계 구현하기 성공...!


🧙‍♀️4. todoReducer

Home에 있던 Annual Plan을 그냥 TodoList로 활용하기로 했다. TodoList인 만큼 유저가 직접 추가하고 달성 여부를 체크하고 원할 때는 삭제도 할 수 있어야 하기 때문에 이 친구들도 Redux를 통해 관리해 보기로 했다.

앞서 만들었던 Diary, WishList와 동일한 방식으로 Reducer를 만들고, Home 컴포넌트에 본격적으로 코드를 짰다. 여러 페이지를 오갈 것도 아니고 모달창을 띄울 것도 아니기 때문에 가장 간단할 줄 알았는데, 의외로 손이 많이 갔다.

🧙‍♀️4-1. todoList

const [todo, setTodo] = useState("");
const dispatch = useDispatch();

const submitHandler = () => {
    if (todo.length > 0) {
      dispatch(
        todoActions.todoCreate({
          id: Math.random(),
          checked: false,
          todo,
        })
      );
      setTodo("");
    }
  };

useEffect(() => {
    const localData = localStorage.getItem("data");

    if (localData) {
      const localTodoList = JSON.parse(localData).todo;
      dispatch(todoActions.todoInit(localTodoList));
    }
}, []);

우선 추가하고자 하는 Todo를 입력할 수 있는 input과 버튼을 만들고, input에 입력한 값을 onChange를 이용해서 todo라는 State에 저장한다. 이때 버튼에 달아줄 submitHandler는 input에 입력한 값이 있을 때만 동작하도록 if문을 사용해서 dispatch를 실행시켰다. 그리고 action이 전달되면 input에 입력했던 값이 사라지도록 setTodo()를 마지막에 실행시켰다.

그리고 useEffect를 사용해서 Home 컴포넌트가 처음 마운트될 때 localStorage에 저장된 todoList가 있으면 불러올 수 있도록 todoInit도 해주었다. 여기까지는 지금까지의 로직과 다를 바 없었다.

🧙‍♀️4-2. CheckForm

이렇게 생성된 todoList는 useSelector를 이용해서 받아온 todoList를 jsx에{todoList.map((item) => (<CheckForm key={item.id} {...item} />))} 이렇게 렌더링되고, 다음은 CheckForm 컴포넌트 코드이다.

const Check = styled.div`

/* 생략 */

  span {
    text-decoration: ${(props) => props.checked === true && "line-through"};
    color: ${(props) => props.checked === true && "gray"};
  }
`;

const CheckForm = ({ id, todo, checked }) => {
  const [isChecked, setIsChecked] = useState(checked);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(
      todoActions.todoEdit({
        id,
        checked: isChecked,
        todo,
      })
    );
  }, [isChecked]);

  const removeHandler = () => {
    dispatch(todoActions.todoRemove(id));
  };

  return (
    <Check checked={isChecked}>
      <label>
        <input
          type="checkbox"
          checked={isChecked}
          onChange={() => setIsChecked(!isChecked)}
        />
        <span>{todo}</span>
      </label>
      <button onClick={removeHandler}>
        <FaTimes />
      </button>
    </Check>
  );
};

우선 체크박스의 체크 여부도 todoList 객체에 저장되기 때문에 해당 데이터의 checked 데이터를 props로 받아와서 isChecked라는 state의 초기값으로 사용한다. 그리고 체크박스에 onChange를 걸어서 체크 여부가 변경될 때마다 isChecked를 변경하고, 이를 styled-components에 props로 전달하면 isChecked가 true가 될 때마다 취소선이 그어지게 된다.

isChecked의 초기값을 checked가 아닌 false로 설정하고 까먹는 바람에 컴포넌트가 재렌더링될 때마다 체크박스가 미체크 상태로 돌아가서 잠시 멘붕이 왔었지만, 다행히 금방 원인을 찾고 수정해서 넘어갈 수 있었다!

🧙‍♀️4-3. Progress Bar

const [totalTodo, setTotalTodo] = useState();
const [completeTodo, setCompleteTodo] = useState();
const [todoProgress, setTodoProgress] = useState();

const todoList = useSelector((state) => state.todo);

const checkedTodo = todoList.filter((item) => item.checked === true);

useEffect(() => {
    if (checkedTodo.length) {
      setTotalTodo(todoList.length);
      setCompleteTodo(checkedTodo.length);
      setTodoProgress(
        String(Math.floor((parseInt(completeTodo) / parseInt(totalTodo)) * 100))
      );
    } else {
      setTodoProgress(0);
    }
  }, [checkedTodo]);

우선 미리 만들어 둔 Progress Bar를 다시 만들었다. 진행률이 변경될 때마다 transition으로 애니메이션 효과를 주고 싶었기 때문이다. 그래서 원래 있던 <progress> 태그를 지우고 일반 div로 다시 만들었다.

이렇게 만든 Progress Bar의 width는 전체 todo 대비 complete한 todo의 비율에 따라 결정되어야 한다. 쉽게 말해 4개 중 1개를 완료했을 때는 25%를 나타내다 추가로 2개를 더 완료했을 때는 75%로 변경되어야 한다.

이를 위해 전체 todo의 개수, 완료한 todo 개수, 그리고 이들을 사용해서 계산된 진행 상태를 저장하기 위해 각각 state를 만든 다음, 계산식을 세워서 state에 넣어 주었다. 그리고 이 state들은 체크된 Todo의 개수가 변할 때마다 렌더링되어야 하므로 useEffect의 의존성 배열에 checkedTodo를 넣어주었다. 마지막으로 todoProgress를 Progress Bar의 styled-components에 props로 전달하면 동적인 Progress Bar도 완성.


🧙‍♀️5. 가벼운 회고

지난 글을 쓸 때까지만 해도 아직 갈 길이 멀어 보였는데, 생각보다 남은 작업이 많지 않은 것 같다. TypeScript 적용한 다음에 Node.js로 서버 만들고 mongoDB에 연결도 해 보고 싶다. 그리고 그렇게 된다면 로그인과 회원가입도 만들어야겠군? 남은 작업이 많지 않은 게 아니네...^^

아, 그리고 사소하지만 파비콘도 바꿨다. 여기에서 귀여운 달팽이를 받아왔는데, 느려도 꾸준히 성장하자는 마음을 담은 선택이었다.

어쨌든 얼른 완성시켜서 최적화한 다음에 배포도 해보고 싶다. 오늘도 열심히 해야쥐~!

0개의 댓글