Todo list 진화시키기

young-gue Park·2023년 2월 6일
0

JavaScript

목록 보기
18/20
post-thumbnail

⚡Todo list 진화시키기


📌 Upgraded Todo List

🔹 지금까지 배운 Vanilla JS 지식들로 Todo list를 새로 진화시켰다. programmers에서 제공해준 api를 이용한다.

고마워요 프로그래머스!

🔹 업그레이드 TodoApp

1) users api를 통해 사용자 목록을 그리고, 클릭하면 해당 사용자의 todo 목록을 가져온다.
2) 할 일을 추가하면 화면에 추가되고, API 호출을 통해 서버에도 추가된다.
3) TODO를 추가하고 삭제하는 동안 낙관적 업데이트를 사용한다.

💡 낙관적 업데이트

  • 서버 요청 성공을 낙관적으로 가정하고 서버의 api를 호출함과 동시에 화면에 변화한 요소를 출력, 출력 이후에 api 호출이 완료되면 다시 업데이트하는 형식을 의미한다.
    new TodoForm({
          $target,
          onSubmit: async (content) => {
              const todo = {
                  content,
                  isCompleted: false
              }
              // 낙관적 업데이트
              this.setState({
                  ...this.state,
                  todos: [
                      ...this.state.todos,
                      todo
                  ]
              })
              await request(`/${this.state.username}`, {
                  method:'POST',
                  body: JSON.stringify(todo)
              })
              await fetchTodo()
          }
      })

4) 서버와 통신하는 동안 서버와 통신중임을 알리는 UI적 처리를 한다.

🔹 핵심인 TodoList

1) 특정 유저의 todo list 불러오기

2) 추가 및 삭제하기

3) 클릭 시, 완료처리하거나 완료처리 취소하기

🔹 구조는 다음과 같다.


📌 구현 코드

index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Todo List</title>
</head>
<body>
    <main id="app" style="display:flex; flex-direction: row;"></main>
    <script src="./src/main.js" type="module"></script>
</body>
</html>

main.js

import App from "./App.js"

const $target = document.querySelector('#app')

new App({$target})

// App.js 생성

api.js

export const API_END_POINT = 'https://cnu1.todo.edu-api.programmers.co.kr'

export const request =  async (url, options = {}) => {
    try {
        const res = await fetch(`${API_END_POINT}${url}`, {
            ...options,
            headers: {
                'Content-Type' : 'application/json'
            }
        })
        if(res.ok) {
            const json = await res.json()
            return json
        }
        throw new Error('API 호출 오류')
    } catch(e) {
        alert(e.message)
    }
}

Header.js

export default function Header({$target, initialState}) {
    const $h2 = document.createElement('h2')
    $target.appendChild($h2)

    this.state = initialState

    this.setState = nextState => {
        this.state = nextState
        this.render()
    }

    this.render = () => {
        const {selectedUsername, isLoading} = this.state
        if(!selectedUsername) {
            $h2.innerHTML = ``
            return
        }
        $h2.innerHTML = `${ selectedUsername } 님의 할일 목록 ${isLoading ? '로딩 중...' : ''}`
    }

    this.render()
}

// 선택된 유저의 이름을 표시

querystring.js

export const parse = (querystring) => 
    // '?name=gue&position=bassist'
    // &로 쪼갠다.
    // key=value의 조합을 object의 형태로 만든다.
    // 만들어진 것을 리턴
    querystring.split('&').reduce((acc, keyAndValue) => {
        const [key, value] = keyAndValue.split('=')

        if(key && value) {
            acc[key] = value
            console.log(acc, key, value)
        }
        return acc
    }, {})

// querystring parsing 함수

TodoList.js

export default function TodoList({$target, initialState, onToggle, onRemove}) {
    const $todo = document.createElement('div')
    $target.appendChild($todo)

    this.state = initialState

    this.setState = (nextState) => {
        this.state = nextState
        this.render()
    }

    this.render = () => {
        const { isLoading, todos } = this.state

        // 로딩중일 때
        if(!isLoading && todos.length===0) {
            $todo.innerHTML = `Todo가 없습니다!`
            return
        }

        $todo.innerHTML = `
            <ul>
                ${todos.map(({_id, content, isCompleted}) => `
                    <li data-id="${_id}" class="todo-item">
                        ${isCompleted ? `<s>${content}</s>` : content}
                        <button class="remove">X</button>
                    </li>
                `).join('')}
            </ul>
        `
        
    }

    // li가 몇 개든 클릭 한번으로 처리가 가능함
    $todo.addEventListener('click', (e) => {
        const $li = e.target.closest('.todo-item')
        
        if($li) {
            const {id} = $li.dataset

            // 실제 이벤트를 발생시킨 곳이 어디인지 찾는 법
            const { className } = e.target

            if(className === 'remove') {
                onRemove(id)
            } else{
                onToggle(id)
            }
        }
    })

    this.render()
}

TodoForm.js

import { setItem, getItem, removeItem } from './storage.js'

const TODO_TEMP_SAVE_KEY = 'TODO_TEMP_SAVE_KEY'
export default function TodoForm({
    $target,
    onSubmit
}) {
    const $form = document.createElement('form')
    $target.appendChild($form)

    this.render = () => {
        $form.innerHTML = `
            <input type = "text" placeholder = "할 일을 입력하세요.">
            <button>추가하기</button>
        `
    }

    $form.addEventListener('submit', (e) => {
        e.preventDefault()

        const $input = $form.querySelector('input')
        const content = $input.value

        onSubmit(content)
        $input.value = ''
        removeItem(TODO_TEMP_SAVE_KEY)
    })
    this.render()

    const $input = $form.querySelector('input')
    $input.value = getItem(TODO_TEMP_SAVE_KEY, '')

    $input.addEventListener('keyup', (e) => {
        setItem(TODO_TEMP_SAVE_KEY, e.target.value)
    })
}

// 로컬 스토리지를 이용해서 저장하기 전의 값을 저장하고 있다가 불러오기
// 임시로 넣었던 값을 삭제

userList.js

export default function UserList({
    $target,
    initialState,
    onSelect
}) {
    const $userList = document.createElement('div')
    $target.appendChild($userList)

    this.state = initialState

    this.setState = nextState => {
        this.state = nextState
        this.render()
    }

    this.render = () => {
        $userList.innerHTML = `
            <h1>Users</h1>
            <ul>
                ${this.state.map(username => `
                    <li data-username="${username}">${username}</li>
                `).join('')}
                <li>
                    <form>
                        <input class="new-user" type = "text" placeholder="add username">
                    </form>
                </li>
            </ul>
        `
    }
    this.render()

    $userList.addEventListener('click', e => {
        const $li = e.target.closest('li[data-username]')

        if($li) { 
            const {username} = $li.dataset
            onSelect(username)
        }
    })

    $userList.addEventListener('submit', e => {
        const $newUser = $userList.querySelector('.new-user')
        const newUserValue = $newUser.value

        if(newUserValue.length > 0) {
            onSelect($newUser.value)
            $newUser.value= ''
        }
        onSelect($newUser.value)
    })
}

storage.js

const storage = window.localStorage

export const setItem = (key, value) => {
    try{
        storage.setItem(key, JSON.stringify(value))
        console.log(key, value)
    } catch(e) {
        console.log(e)
    }
}
export const getItem = (key, defaultValue) => {
    try {
        const storedValue = storage.getItem(key)

        if(!storedValue) {
            return defaultValue
        }

        const parsedValue = JSON.parse(storedValue)
        return parsedValue
    }   catch(e) {
        return defaultValue
    }
}

export const removeItem = (key) => {
    storage.removeItem(key)
}

🌟App.js

import Header from "./Header.js"
import UserList from "./userList.js"
import TodoForm from "./TodoForm.js"
import TodoList from "./TodoList.js"
import { request } from "./api.js";
import { parse } from "./querystring.js";

export default function App({
    $target
}) {
    const $userListContainer = document.createElement('div')
    const $todoListContainer = document.createElement('div')

    $target.appendChild($userListContainer)
    $target.appendChild($todoListContainer)

    this.state = {
        userList: [],
        selectedUsername: null,
        todos: [],
        isLoading: false
    }

    // querylist 안에 있는 유저를 선택처리, 그게 아니면 클릭할 때 유저가 처리되는 형식
    const userList = new UserList({
        $target: $userListContainer,
        initialState: this.state.userList,
        onSelect: async (username) => {
            history.pushState(null, null, `/?selectedUsername=${username}`)
            this.setState({
                ...this.state,
                selectedUsername: username
            })
            await fetchTodo()
        }
    })

    const header = new Header({
        $target: $todoListContainer,
        initialState:{
            selectedUsername: this.state.selectedUsername,
            isLoading: this.state.isLoading
        }
    })
    

    new TodoForm({
        $target: $userListContainer,
        onSubmit: async (content) => {
            const isFirstTodoAdd = this.state.todos.length === 0
            const todo = {
                content,
                isCompleted: false
            }
            this.setState({
                ...this.state,
                todos: [
                    ...this.state.todos,
                    todo
                ]
            })

            await request(`/${this.state.selectedUsername}`, {
                method:'POST',
                body: JSON.stringify(todo)
            })
            await fetchTodo()

            if(isFirstTodoAdd) {
                await fetchUserList()
            }
        }
    })
    
    this.setState = nextState => {
        this.state = nextState

        header.setState({
            isLoading: this.state.isLoading,
            selectedUsername: this.state.selectedUsername
        })

        todoList.setState({
            isLoading: this.state.isLoading,
            todos: this.state.todos,
            selectedUsername: this.state.selectedUsername
        })

        userList.setState(this.state.userList)
        this.render()
    }

    this.render = () => {
        const { selectedUsername } = this.state
        $todoListContainer.style.display = selectedUsername ? 'block' : 'none'
    }

    const todoList = new TodoList({
        $target: $userListContainer,
        initialState: {
            isLoading: this.state.isLoading,
            todos: this.state.todos,
            selectedUsername: this.state.selectedUsername
        },
        onToggle: async (id) => {
            const todoIndex = this.state.todos.findIndex(todo => todo._id === id)

            const nextTodos = [...this.state.todos]
            nextTodos[todoIndex].isCompleted = !nextTodos[todoIndex].isCompleted
            this.setState({
                ...this.state,
                todos: nextTodos
            })
            await request(`/${this.state.selectedUsername}/${id}/toggle?delay=1000`, {
                method: 'PUT'
            })
            await fetchTodo()
        },
        onRemove: async (id) => {
            const todoIndex = this.state.todos.findIndex(todo => todo._id === id)

            const nextTodos = [...this.state.todos]
            nextTodos.splice(todoIndex, 1)
            this.setState({
                ...this.state,
                todos: nextTodos
            })
            await request(`/${this.state.selectedUsername}/${id}`, {
                method: 'DELETE'
            })
            await fetchTodo()
        }
        
    })
    const fetchUserList = async () => {
        const userList = await request('/users')
        this.setState({
            ...this.state,
            userList
        })
    }

    const fetchTodo = async () => {
        const {selectedUsername} = this.state

        if(selectedUsername) {
            this.setState({
                ...this.state,
                isLoading: true
            })
            const todos = await request(`/${selectedUsername}`)
            this.setState({
                ...this.state,
                todos,
                isLoading: false
            })
        }   
    }

    const init = async () => {
        await fetchUserList()

        // url에 특정 사용자를 나타내는 값이 있을 경우
        const { search } = location

        if (search.length > 0) {
            const {selectedUsername} = parse(search.substring(1))

            if (selectedUsername) {
                this.setState({
                    ...this.state,
                    selectedUsername
                })
                await fetchTodo()
            }
        }
    }
    
    this.render()
    init()
    
    window.addEventListener('popstate', () => {
        init()
    })
}

🖨 결과물

TodoList가 할일 목록 아래에 붙지 않는거 외엔 정상 작동한다.
저게 왜 저러는지는... 주말을 이용해 천천히 디버깅 해보아야겠다.


다양한 비동기 제어방식들과 api, 로컬 스토리지, history api까지 이용하여 만들었다.
다양한 기능이 추가된 만큼 이전보다 더 나은 구성이 되었다.

profile
Hodie mihi, Cras tibi

0개의 댓글