투두 목록 페이지는 아래의 조건에 맞게 구현하면 된다.

1. 투두 리스트 페이지 컨테이너 컴포넌트

/src/api/todos.js

import axios from 'axios';

const token = localStorage.getItem('auth');

const config = {
  headers: {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
};

export const createTodoApi = async (payload) => {
  const res = await axios.post(
    `${process.env.REACT_APP_SERVER_URL}todos`,
    payload,
    config
  );

  return res;
};

export const getTodosApi = async () => {
  const res = await axios.get(
    `${process.env.REACT_APP_SERVER_URL}todos`,
    config
  );

  return res;
};

export const updateTodoApi = async (id, body) => {
  const res = await axios.put(
    `${process.env.REACT_APP_SERVER_URL}todos/${id}`,
    body,
    config
  );

  return res;
};

export const deleteTodoApi = async (id) => {
  const res = await axios.delete(
    `${process.env.REACT_APP_SERVER_URL}todos/${id}`,
    config
  );

  return res;
};

일단 먼저 투두 페이지에서 사용할 api들을 모듈화 해주었다.

 

/src/todos/pages/todos.jsx

function Todos() {
  const [todos, setTodos] = useState([]);

  // 토큰 없는 상태로 /todo경로 접근 시 로그인 페이지로 리다이렉트
  useEffect(() => {
    const hasToken = localStorage.getItem('auth');
    if (!hasToken) {
      window.location.replace('/signin');
    }
  }, []);

  // 투두 목록 조회
  useEffect(() => {
    const dataFetch = async () => {
      const res = await getTodosApi();

      if (res.status !== 200) {
        return;
      }
      setTodos(res.data);
    };

    dataFetch();
  }, []);

  return (
    <div className='todos-container'>
      <Header>
        <NewTodoForm setTodos={setTodos} />
      </Header>
      <TodoList todos={todos} setTodos={setTodos} />
    </div>
  );
}

export default Todos;

1. 먼저 서버에서 응답 받은 투두 목록을 넣어줄 state인 todos를 만들었다.

2. useEffect를 이용하여 로그인 토큰이 없을 시에는 window.location.replace('/signin');를 통하여 로그인 페이지로 리다이렉트 시켜주게 구현하였다.

3. useEffect를 이용하여 컴포넌트 마운트 시 투두 목록 조회 api를 호출하여 투두 목록을 받아오게 하였다.

4. 자식 컴포넌트인 NewTodoform과 TodoList컴포넌트에 todos state에 상태관리를 할 수 있게 setTodos 함수를 props로 내려 주었다.(투두 추가, 수정, 삭제 했을 때 state에 반영해서 바로 UI변화를 일어나게 하기 위함)

5. NewTodoForm 컴포넌트 즉 투두 추가 폼을 헤더 안에 위치 시키기 위해서 props.Children으로 헤더 컴포넌트에 전달해주었다.

 

src/UIElements

function Header(props) {
  // 로그아웃
  const logoutHandler = () => {
    localStorage.clear();
    window.location.replace('/signin');
  };

  return (
    <header className='header'>
      <div className='header__title'>
        <h1>Todo List</h1>
      </div>
      <div className='header__add-form'>{props.children}</div>
      <div className='header__logout-btn'>
        <button onClick={logoutHandler}>로그아웃</button>
      </div>
    </header>
  );
}

export default Header;

또한 헤더 안에 로그아웃 버튼을 만들어 로그아웃 시 로그인 페이지로 이동하게 하였다.

2. 투두 추가 폼 구현

/src/todos/components/NewTodoForm.jsx

function NewTodoForm({ setTodos }) {
  const [newTodo, setNewTodo] = useState('');

  // 투두 텍스트 입력
  const todoChangeHandler = (event) => {
    setNewTodo(event.target.value);
  };

  // 투두 추가
  const sumbitHandler = async (event) => {
    event.preventDefault();
    const body = {
      todo: newTodo,
    };

    try {
      const res = await createTodoApi(body);
      if (res.status !== 201) {
        alert('할 일 추가에 실패하였습니다.');
        return;
      }

      setTodos((prev) => [...prev, res.data]);
    } catch (error) {
      alert('에러가 발생하였습니다.');
      console.log(error);
    } finally {
      setNewTodo('');
    }
  };

  return (
    <form onSubmit={sumbitHandler} className='new-todo-form'>
      <div className='new-todo-form__input-container'>
        <input
          data-testid='new-todo-input'
          type='text'
          onChange={todoChangeHandler}
          value={newTodo}
        />
        <button data-testid='new-todo-add-button' type='submit'>
          추가
        </button>
      </div>
    </form>
  );
}

export default NewTodoForm;

1. onChange 이벤트에 todoChangeHandler함수를 달아 새로운 투두 텍스트가 입력 되도록 하였다.

2. submitHandler함수 안에 try catch문으로 예외처리를 해주었다.

3. 투두 추가 요청 성공 시 응답 받아온 데이터를 setTodos((prev) => [...prev, res.data]);를 이용하여 state에 새로 추가 시켜주었다.

3. 투두 수정, 삭제

src/todos/components/TodoList.jsx

function TodoList({ todos, setTodos }) {
  if (todos.length === 0) {
    return <span className='no-todo'>아직 추가된 할 일이 없습니다.</span>;
  }

  return (
    <ul className='todo-list'>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          id={todo.id}
          todo={todo.todo}
          isCompleted={todo.isCompleted}
          setTodos={setTodos}
        />
      ))}
    </ul>
  );
}

export default TodoList;

1. 부모컴포넌트에서 받아온 todos state가 빈 배열일 시(데이터가 없을 시) if문을 활용하여 '아직 추가된 할 일이 없습니다.'라는 문구를 화면에 표시하도록 하였다.

2. 데이터가 있을 때에는 map 배열함수를 이용하여 TodoItem컴포넌트에 투두 데이터를 props로 전달해주고

todos state를 상태관리 할 수 있는 setTodos함수도 전달해주었다.

 

src/todos/components/TodoItem.jsx

TodoItem 컴포넌트 전체 코드

function TodoItem({ id, todo, isCompleted, setTodos }) {
  // 수정모드 여부 state
  const [isEditMode, setIsEditMode] = useState(false);

  // 수정모드 시 기존 투두 텍스트를 가져와서 변경하기 위한 state
  const [editedTodo, setEditedTodo] = useState(todo);

  // 투두 완료 체크
  const completeCheckHandler = async () => {
    const body = {
      todo,
      isCompleted: !isCompleted,
    };

    const res = await updateTodoApi(id, body);
    if (res.status !== 200) return;

    setTodos((prev) =>
      prev.map((todo) => (todo.id === res.data.id ? res.data : todo))
    );
  };

  // 수정모드 진입
  const editModeHandler = () => {
    setIsEditMode(true);
  };

  // 수정모드 취소
  const editModeCancelHandler = () => {
    setIsEditMode(false);

    // 취소 한 후 다시 수정모드 진입 시 서버로부터 받아온 기존의 투두 텍스트를 유지하기 위함
    setEditedTodo(todo);
  };

  // 수정할 텍스트 입력
  const todoChangeHandler = (event) => {
    setEditedTodo(event.target.value);
  };

  // 수정사항 제출
  const updateTodoHandler = async () => {
    const body = {
      todo: editedTodo,
      isCompleted,
    };

    try {
      const res = await updateTodoApi(id, body);
      if (res.status !== 200) {
        alert('수정 하는데 실패하였습니다.');
      }

      setTodos((prev) =>
        prev.map((todo) => (todo.id === res.data.id ? res.data : todo))
      );
      setIsEditMode(false);
    } catch (error) {
      alert('수정 하는데 실패하였습니다.');
      console.log(error);
    }
  };

  // 투두 삭제
  const deleteTodoHandler = async () => {
    try {
      const res = await deleteTodoApi(id);
      if (res.status !== 204) {
        alert('삭제 하는데 실패하였습니다.');
      }

      setTodos((prev) => prev.filter((todo) => todo.id !== id));
    } catch (error) {
      alert('에러가 발생하였습니다.');
      console.log(error);
    }
  };

  return (
    <li className='todo-item'>
      <label>
        <input
          type='checkbox'
          checked={isCompleted}
          onChange={completeCheckHandler}
        />
        {isEditMode ? (
          <input
            data-testid='modify-input'
            value={editedTodo}
            onChange={todoChangeHandler}
            className='todo-item__modify-input'
          />
        ) : (
          <span>{todo}</span>
        )}
      </label>
      <div className='todo-item__btn'>
        {isEditMode ? (
          <>
            <button data-testid='submit-button' onClick={updateTodoHandler}>
              제출
            </button>
            <button data-testid='cancel-button' onClick={editModeCancelHandler}>
              취소
            </button>
          </>
        ) : (
          <>
            <button
              data-testid='modify-button'
              className='todo-item__btn__modify'
              onClick={editModeHandler}
            >
              <FaPencilAlt />
            </button>
            <button
              data-testid='delete-button'
              className='todo-item__btn_delete'
              onClick={deleteTodoHandler}
            >
              <FaTrashAlt />
            </button>
          </>
        )}
      </div>
    </li>
  );
}

export default TodoItem;

 

1. 투두 완료 체크

// 투두 완료 체크
  const completeCheckHandler = async () => {
    const body = {
      todo,
      isCompleted: !isCompleted,
    };

    const res = await updateTodoApi(id, body);
    if (res.status !== 200) return;

    setTodos((prev) =>
      prev.map((todo) => (todo.id === res.data.id ? res.data : todo))
    );
  };

1. 체크 박스를 누르면 completeCheckHandler 함수가 작동하게 해서 서버로부터 응답 받아온 기존 투두 데이터의 isCompleted값을 반대로 하고, 기존 todo 텍스트와 함께 body에 담아서 수정api를 통하여 서버에 수정 요청을 하였다.

2. 수정 요청 후 응답 받아온 데이터를 setTodos((prev) =>
      prev.map((todo) => (todo.id === res.data.id ? res.data : todo))
    ); 를 이용하여 state의 투두 id와 응답 받아온 투두의 id가 같으면 응답 받아온 데이터(수정된 데이터)로 바꿔주고 그렇지 않으면 기존 state의 투두 데이터로 유지하는 형태로 새롭게 배열을 만든 뒤 state에 넣어 주었다.

 

2. 투두 텍스트 수정

  // 수정모드 여부 state
  const [isEditMode, setIsEditMode] = useState(false);
  
  // 수정모드 시 기존 투두 텍스트를 가져와서 변경하기 위한 state
  const [editedTodo, setEditedTodo] = useState(todo);

  // 수정모드 진입
  const editModeHandler = () => {
    setIsEditMode(true);
  };

  // 수정모드 취소
  const editModeCancelHandler = () => {
    setIsEditMode(false);

    // 취소 한 후 다시 수정모드 진입 시 서버로부터 받아온 기존의 투두 텍스트를 유지하기 위함
    setEditedTodo(todo);
  };

1. 수정모드 인지 알기 위한 isEditMode state에 기본 값으로 false를 주어서 처음엔 수정모드가 아닌 상태로 시작

2. eidtModeHandler함수를 통해 isEditMode state를 true로 변경 시켜 수정모드 진입

3. eidtModeCancelHandler함수를 통해 isEditMode state를 false로 변경 시켜 수정모드를 취소하고 취소한 후 수정 내용을 초기화하기 위해 setEditedTodo()를 통해 기존 todo텍스트를 넣어줌

 

  // 수정모드 시 기존 투두 텍스트를 가져와서 변경하기 위한 state
  const [editedTodo, setEditedTodo] = useState(todo);
  
  // 수정할 텍스트 입력
  const todoChangeHandler = (event) => {
    setEditedTodo(event.target.value);
  };

  // 수정사항 제출
  const updateTodoHandler = async () => {
    const body = {
      todo: editedTodo,
      isCompleted,
    };

    try {
      const res = await updateTodoApi(id, body);
      if (res.status !== 200) {
        alert('수정 하는데 실패하였습니다.');
      }

      setTodos((prev) =>
        prev.map((todo) => (todo.id === res.data.id ? res.data : todo))
      );
      setIsEditMode(false);
    } catch (error) {
      alert('수정 하는데 실패하였습니다.');
      console.log(error);
    }
  };

1. 수정모드 시 기존 투두 텍스트를 가져 와서 변경하기 위해 editTodo state를 만들고 기존 투두 텍스트를 기본값으로 넣어줌

2. TodoChangeHandler 함수로 수정하기 위해 입력한 값을 editTodo state에 넣어줌

3. updateTodoHandler 함수를 통해서 수정한 데이터를 body에 담아 api요청을 하고 수정에 성공하면

setTodos((prev) =>
        prev.map((todo) => (todo.id === res.data.id ? res.data : todo))
      );를 이용해서 서버로부터 응답 받은 수정된 데이터로 바꿔줌

4. 수정이 성공하면 수정모드를 false로 하여 수정모드를 나감

 

 return (
    <li className='todo-item'>
      <label>
        <input
          type='checkbox'
          checked={isCompleted}
          onChange={completeCheckHandler}
        />
        {isEditMode ? (
          <input
            data-testid='modify-input'
            value={editedTodo}
            onChange={todoChangeHandler}
            className='todo-item__modify-input'
          />
        ) : (
          <span>{todo}</span>
        )}
      </label>
      <div className='todo-item__btn'>
        {isEditMode ? (
          <>
            <button data-testid='submit-button' onClick={updateTodoHandler}>
              제출
            </button>
            <button data-testid='cancel-button' onClick={editModeCancelHandler}>
              취소
            </button>
          </>
        ) : (
          <>
            <button
              data-testid='modify-button'
              className='todo-item__btn__modify'
              onClick={editModeHandler}
            >
              <FaPencilAlt />
            </button>
            <button
              data-testid='delete-button'
              className='todo-item__btn_delete'
              onClick={deleteTodoHandler}
            >
              <FaTrashAlt />
            </button>
          </>
        )}
      </div>
    </li>
  );

삼항연산자를 활용하여 수정모드가 true일 때와 false일 때 input창이 생기고 버튼을 다르게 렌더링 시켜줌

 

3. 투두 삭제

  // 투두 삭제
  const deleteTodoHandler = async () => {
    try {
      const res = await deleteTodoApi(id);
      if (res.status !== 204) {
        alert('삭제 하는데 실패하였습니다.');
      }

      setTodos((prev) => prev.filter((todo) => todo.id !== id));
    } catch (error) {
      alert('에러가 발생하였습니다.');
      console.log(error);
    }
  };

투두 삭제 api를 요청하고 성공하면 setTodos((prev) => prev.filter((todo) => todo.id !== id));를 이용하여

삭제한 투두가 아닌 것만 필터링해서 남겨줌(삭제한 것만 없어짐)

+ Recent posts