Upgraded Todo List

나혜수·2023년 2월 20일
0

자바스크립트 실전

목록 보기
15/19

시작하기 앞서 . . .

JSON

JSON ( JavaScript Object Notation )은 Javascript 객체 문법으로 구조화된, 데이터를 표현하기 위한 문자 기반의 표준 포맷이다. 웹 어플리케이션에서 데이터를 전송할 때 일반적으로 사용한다. 요즘, 대부분 요청에 대한 Content-Typeapplication/json 타입인 것이 많다.

method기능용도
JSON.parse( )string 객체를 → json 객체로 변환서버에서 받은 json을 해석할 때 사용
JSON.stringify( )json 객체를 → string 객체로 변환서버로 json을 보낼 때 사용
response.json( )string 객체를 → json 객체로 변환응답의 body 안의 json을 해석할 때 사용

🧐 JSON.parse(response), response.json() 차이
response.json()는 json안에 header와 body 전부를 넣어도 body만 해석
JSON.parse(response)는 header와 body를 전부 넣어주면 해석 불가능


Upgraded Todo List

그동안 배운 지식을 활용해 simple Todo list를 Todo App으로 발전시켜보자.
이전에 배운 history API, fetch를 이용할 것이다.


구현기능

  1. 특정 사용자의 Todo 목록 불러오기
  2. 추가 및 삭제하기
  3. 클릭 시 완료처리 or 완료처리 취소하기

요구사항

  1. users API를 통해 사용자 목록을 그리고, 클릭하면 해당 사용자의 Todo 목록을 가져오게 한다.
  2. 할 일을 추가하면 화면에 추가되고, API 호출을 통해 서버에도 추가된다.
  3. Todo를 추가하고 삭제하는 동안 낙관적 업데이트를 사용한다.
  4. 서버와 통신하는 동안 서버와 통신 중임을 알리는 UI 처리를 한다.

낙관적 업데이트
서버 요청 성공을 낙관적으로 가정하여 서버 API를 호출함과 동시에 화면에 변화한 요소를 출력한다. 만약 출력 이후에 API 호출이 완료되면 다시 업데이트를 한다.

new TodoForm({
        $target,
        onSubmit: async(content) => {
            // 1. 서버에 post하기 전에 todo를 todoList에 추가 (낙관적 업데이트)
            const todo = {
                content,
                isCompleted: false
            }
            this.setState({
                ...this.state,
                todos: [
                    ...this.state.todos,
                    todo
                ]
            })
            // 2. 서버에 추가하기 
            await request(`/:${this.state.username}`, {
                method: 'POST',
                body: JSON.stringify(todo) 
            })
        }})

파일 구조


API 사용법

  1. 데이터 형태
    {
     "_id": 할 일의 고유값. 숫자와 문자가 섞여있는 문자로 되어있음,
     "content": 할 일 text,
     "isCompleted": 할 일의 완료 여부
    }
    API_END_POINT = https://cnu1.todo.edu-api.programmers.co.kr
  1. 유저 목록 불러오기
  • API URL : https://cnu1.todo.edu-api.programmers.co.kr/users
    URL /users
    ["programmers","null","test","dns-query","query","resolve",
    "HNAP1","최효재",".env","index.htm","FD873AC4-CF86-4FED-84EC-4BD59C6F17A7",
    "dddf","이게 정말 되나","users","sdk","posts",".env.prod",".env.old",".env.development",
    ".env.production",".env.project","live_env",".env.dist",".env.save","json-rpc"]
  • method : GET
    // ex) 유저 목록 불러오기
    fetch('https://cnu1.todo.edu-api.programmers.co.kr/users').then()...
  1. 할 일 목록 불러오기
[
  {"content":"","isCompleted":false,"_id":"63eee8e498dd4702950c5224"},
  {"content":"","isCompleted":false,"_id":"63fdf1ea98dd4702950c5277"},
  {"content":"","isCompleted":false,"_id":"63fe11af98dd4702950c5278"},
  {"content":"","isCompleted":false,"_id":"63ff361298dd4702950c527e"},
  {"content":"","isCompleted":false,"_id":"63ff475e98dd4702950c5280"},
  {"content":"","isCompleted":false,"_id":"6400150e98dd4702950c5284"}
]
  • method : GET
    // ex) 할 일과 관련된 모든 API에는 username이 들어가게 되어있다. 
    // 본인의 username을 적당히 넣으면 된다. 
    fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk').then()...
  1. 할 일 추가하기
  • API URL : https://cnu1.todo.edu-api.programmers.co.kr/:username
    URL /:username
  • method : POST
    // ex) ":username"에 지정한 값이 할 일과 동시에 유저 목록에 추가
    fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json'
     },
     body: JSON.stringify({
       content: 'Javascript 실습'
     })
    }).then(function(){
     ....
    })
  1. 할 일 삭제하기
  • API URL : https://cnu1.todo.edu-api.programmers.co.kr/:username/:todo_id
    URL /:username/:todo_id
  • method: Delete
    /* ex)  서버에서 불러온 todo 데이터는 _id 라는 이름으로 해당 todo의 id가 있다.
    이것을 url의 <todo_id> 부분에 넣으면 된다. */
    fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk/5d11cf671e050d3f7c583166', {
     method: 'DELETE'
    }).then(function(){
     ....
    })
  1. 할 일 완료 여부 토글하기
  • API URL : https://cnu1.todo.edu-api.programmers.co.kr/:username/:todo_id/toggle
    URL /:username/:todo_id/toggle
  • method : PUT
    /* ex) todo_id에 해당하는 todo가 완료 상태인 경우 미완료 처리, 
            미완료 상태인 경우 완료 처리를 한다. */
    fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk/63d875575ad9f10afacbab57/toggle', {
     method: 'PUT'
    }).then(function(){


1. TodoList,TodoForm.js 만들고 더미 데이터로 확인하기

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>upgraded TodoList</title>
</head>
<body>
    <main id="app"></main>
    <script src="/src/main.js" type="module"></script>
</body>
</html>

main.js

import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"

const DummyData = [
    // 유저별 할일 목록 데이터 형태 
    {
        _id : 1,
        content: "javascript 학습하기",
        isCompleted: true
    },
    {
        _id : 2,
        content: "javascript 복습하기",
        isCompleted: false
    }
]

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

new TodoForm({
    $target: $App,
    onSubmit: (content) => {
        alert(`${content} 추가 예정`)
    }})
    
new TodoList({
    $target: $App, 
    initialState: DummyData, 
    onToggle: (id) => {alert(`${id} 토글 예정`)}, 
    onRemove: (id) => {alert(`${id} 삭제 예정`)}
}) 

TodoList.js

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

    this.state = initialState
    /* [
            // 유저별 할일 목록 데이터 형태 
            {
                _id : 1,
                content: "javascript 학습하기",
                isCompleted: true
            },
            {
                _id : 2,
                content: "javascript 복습하기",
                isCompleted: false
            }
        ] 
    */

    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>
                ${this.state.map(({_id, content, isCompleted}) => `
                   <li data-id="${_id}" class="todo-item">
                      ${isCompleted ? `<s>${content}</s>` : content} 
                                  <!-- <s></s> 취소선 처리 --> 
                      <button class="remove"> X </button>
                   </li>
                `).join('')}
            </ul>
        `
        
    }

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

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

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

    this.render()
}

TodoForm.js

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

    this.render = () => {
        $form.innerHTML = `
            <input type='text' placeholder='할 일을 입력하세요.'></input>
            <button type='submit'> 추가하기 </button> <!-- 버튼 타입의 기본값은 submit -->
        `
        
        $form.addEventListener('submit', (e) => {
            e.preventDefault() 

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

            onSubmit(content)

            $input.value = ''
        })
    }

    this.render()
    
}


2. TodoList,TodoForm을 App으로 옮기고 Api 연동하기

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 $h1 = document.createElement('h1')
    $target.appendChild($h1)

    this.state = initialState // username, isLoading 

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

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

    this.render()
}

TodoList.js

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

    this.state = initialState // {isLoading, todos: []}
    /* [
            // 유저별 할일 목록 데이터 형태 
            {
                _id : 1,
                content: "javascript 학습하기",
                isCompleted: true
            },
            {
                _id : 2,
                content: "javascript 복습하기",
                isCompleted: false
            }
        ] 
    */

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

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


        if (!isLoading && todos.length.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') 
        // => <li data-id="${_id}" class="todo-item">
        
        if($li) {
            const {id} = $li.dataset

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

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

    this.render()
}

TodoForm.js

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

    this.render = () => {
        $form.innerHTML = `
            <input type='text' placeholder='할 일을 입력하세요.'></input>
            <button type='submit'> 추가하기 </button> 
        `
        
        $form.addEventListener('submit', (e) => {
            e.preventDefault() 

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

            onSubmit(content)

            $input.value = ''
        })
    }

    this.render()
    
}

App.js

import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"

export default function App({$target}){
    this.state = {
        username: 'programmers',
        todos: [],
        isTodoLoading: false
        /*
        [ // todos 형태 
            {
                _id : 1,
                content: "javascript 학습하기",
                isCompleted: true
            },
            {
                _id : 2,
                content: "javascript 복습하기",
                isCompleted: false
            }
        ] 
        */
    }

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

    new TodoForm({
        $target,
        onSubmit: async(content) => {

            // todoList에 반영
            // 낙관적 업데이트 (사용성 굿)
            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) 
            })

             // todoList에 반영 (낙관적 업데이트가 아닐 때)
             // await fetchTodos()

        }})

    this.setState = (nextState) =>{
        this.state = nextState
        todoList.setState({
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos})
        header.setState({
            username:this.state.username,
            isLoading: this.state.isTodoLoading
        })    
    }    
        
    const todoList = new TodoList({
        $target, 
        initialState: {
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos}, 

        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.username}/${id}`,{
                method: 'DELETE'
            })
        }, 

        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.username}/${id}/toggle`,{
                method: 'PUT',
            })
        }
    })


    // username별 todo 목록 불러오기 
    const fetchTodos = async () => {
        const {username} = this.state

        // 현재 username이 있을 때만 todo 목록을 불러온다. 
        if(username){
            // 데이터를 불러오는 로딩중일 때 
            this.setState({
                ... this.state,
                isTodoLoading: true
            })

            // request
            const todos = await request(`/${username}`)
            // 로딩중 확인할 때 `/${username}?delay=1000`

            this.setState({
                ...this.state,
                todos,
                isTodoLoading: false // 로딩이 끝나면 false 처리 
            })
        }
    }

    fetchTodos()

}

main.js

import App from "./App.js"

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

new App({$target})


3. local storage

만약 input 작성 중 저장 or 추가하기 버튼을 누르지 않은 경우, 새로고침 시 이전 값을 채워주는 작업을 local storage를 이용해 구현할 것이다. 값을 추가하기 전 새로고침을 하면 input에 작성 중인 값이 그대로 남아있고, 값을 추가 후 새로고침을 하면 input이 비워져 있다.

storage.js

const storage = window.localStorage

export const setItem = (key,value) => {
    try{
        storage.setItem(key,JSON.stringify(value))
    } catch(e){
        console.log(e)
    } 
    /* 브라우저마다 setItem 용량 제한이 있다. 
       용량을 초과해서 데이터를 저장하려고 하면 에러가 발생하므로 
       try catch 처리를 해주는 것이 안전하다. */

}

export const getItem = (key, defaultValue) => {
     // 꺼내오는데 실패 시를 대비해 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)
}

TodoForm.js

import { setItem, getItem, removeItem } from "./storage.js"
const TODO_TEXT_SAVE_KEY = 'TODO_TEXT_SAVE_KEY'

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

    this.render = () => {
        $form.innerHTML = `
            <input type='text' placeholder='할 일을 입력하세요.'></input>
            <button type='submit'> 추가하기 </button> 
        `
        
        $form.addEventListener('submit', (e) => {
            e.preventDefault() 

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

            onSubmit(content)

            $input.value = ''

            // 데이터가 추가되면 로컬 스토리지 삭제 
            removeItem(TODO_TEXT_SAVE_KEY)
        })
    }

    this.render()

    // 렌더링 된 이후에 .. 로컬스토리지 처리 
    const $input = $form.querySelector('input')
    $input.value = getItem(TODO_TEXT_SAVE_KEY,'')
    
    $input.addEventListener('keyup', (e)=>{
        setItem(TODO_TEXT_SAVE_KEY, e.target.value)
    })
}

4. userList 구현

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='newUser' type='text' placeholder='add new User'>
                    </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('.newUser')

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

App.js

import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"
import UserList from "./userList.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: [],
        isTodoLoading: false
        /*
        [ // todos 형태 
            {
                _id : 1,
                content: "javascript 학습하기",
                isCompleted: true
            },
            {
                _id : 2,
                content: "javascript 복습하기",
                isCompleted: false
            }
        ] 
        */
    }

    const userList = new UserList({
        $target: $userListContainer,
        initialState: this.state.userList,
        onSelect: async (username) => {
            this.setState({
                ...this.state,
                selectedUsername: username
            })

            await fetchTodos()
            $todoListContainer.style.display = 'block'
        }
    })

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

    new TodoForm({
        $target: $todoListContainer,
        onSubmit: async(content) => {
            const isFirstTodoAdd = this.state.todos.length === 0

            // todoList에 반영
            // 낙관적 업데이트 (사용성 굿)
            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) 
            })

            // 새 유저에 처음 todo 추가 시 유저리스트에 새 유저 바로 보이게 하기 
            if(isFirstTodoAdd){
                await fetchUserList()
            }

             // todoList에 반영 (낙관적 업데이트가 아닐 때)
             // await fetchTodos()

        }})

    this.setState = (nextState) =>{
        this.state = nextState
        userList.setState(this.state.userList)
        todoList.setState({
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos})
        header.setState({
            username:this.state.selectedUsername,
            isLoading: this.state.isTodoLoading
        })    
    } 
    

    // selectedUsername가 없을 땐 $todoListContainer 그리지 않는 처리 
    this.render = () => {
        const { selectedUsername } = this.state
        $todoListContainer.style.display = selectedUsername ? 'block' : 'none'
    }
        
    const todoList = new TodoList({
        $target: $todoListContainer, 
        initialState: {
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos,
        }, 

        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'
            })
        }, 

        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`,{
                method: 'PUT',
            })
        }
    })

    // username 불러오기 
    const fetchUserList = async () => {
        const userList = await request('/users')

        this.setState({
            ...this.state,
            userList: userList
        })
    }


    // username별 todo 목록 불러오기 
    const fetchTodos = async () => {
        const {selectedUsername} = this.state

        // 현재 username이 있을 때만 todo 목록을 불러온다. 
        if(selectedUsername){
            // 데이터를 불러오는 로딩중일 때 
            this.setState({
                ... this.state,
                isTodoLoading: true
            })

            // request
            const todos = await request(`/${selectedUsername}`)
            // 로딩중 확인할 때 `/${selectedUsername}?delay=1000`

            this.setState({
                ...this.state,
                todos,
                isTodoLoading: false // 로딩이 끝나면 false 처리 
            })
        }
    }

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

    init()

    this.render()
}


5. history API

history API를 이용해서 유저를 선택하면 URL을 업데이트 해주고, 처음 URL에 따라 특정 유저의 TodoList가 보이도록 수정한다.

querystring.js

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

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

App.js

import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"
import UserList from "./userList.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: [],
        isTodoLoading: false
        /*
        [ // todos 형태 
            {
                _id : 1,
                content: "javascript 학습하기",
                isCompleted: true
            },
            {
                _id : 2,
                content: "javascript 복습하기",
                isCompleted: false
            }
        ] 
        */
    }

    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 fetchTodos()
        }
    })

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

    new TodoForm({
        $target: $todoListContainer,
        onSubmit: async(content) => {
            const isFirstTodoAdd = this.state.todos.length === 0

            // todoList에 반영
            // 낙관적 업데이트 (사용성 굿)
            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) 
            })

            // 새 유저에 처음 todo 추가 시 유저리스트에 새 유저 바로 보이게 하기 
            if(isFirstTodoAdd){
                await fetchUserList()
            }

             // todoList에 반영 (낙관적 업데이트가 아닐 때)
             // await fetchTodos()

        }})

    this.setState = (nextState) =>{
        this.state = nextState
        userList.setState(this.state.userList)
        todoList.setState({
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos})
        header.setState({
            username:this.state.selectedUsername,
            isLoading: this.state.isTodoLoading  
        })  
        this.render()    
    } 
    

    // selectedUsername가 없을 땐 $todoListContainer 그리지 않는 처리 
    this.render = () => {
        const { selectedUsername } = this.state
        $todoListContainer.style.display = selectedUsername ? 'block' : 'none'
    }
        
    const todoList = new TodoList({
        $target: $todoListContainer, 
        initialState: {
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos,
        }, 

        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'
            })
        }, 

        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`,{
                method: 'PUT',
            })
        }
    })

    // username 불러오기 
    const fetchUserList = async () => {
        const userList = await request('/users')

        this.setState({
            ...this.state,
            userList: userList
        })
    }


    // username별 todo 목록 불러오기 
    const fetchTodos = async () => {
        const {selectedUsername} = this.state

        // 현재 username이 있을 때만 todo 목록을 불러온다. 
        if(selectedUsername){
            // 데이터를 불러오는 로딩중일 때 
            this.setState({
                ... this.state,
                isTodoLoading: true
            })

            // request
            const todos = await request(`/${selectedUsername}`)
            // 로딩중 확인할 때 `/${selectedUsername}?delay=1000`

            this.setState({
                ...this.state,
                todos,
                isTodoLoading: false // 로딩이 끝나면 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 fetchTodos()
            }
        }   
    }

    window.addEventListener('popstate',(e)=>{
        init()
    })

    this.render()
    init()
}

6. 최종 코드

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>upgraded TodoList</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') // <main> 태그 선택 

new App({$target}) // App 함수 선언은 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 // username, isLoading 

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

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

    this.render()
}

querystring.js

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

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

TodoList.js

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

    this.state = initialState // {isLoading, todos: []}
    /* [
            // 유저별 할일 목록 데이터 형태 
            {
                _id : 1,
                content: "javascript 학습하기",
                isCompleted: true
            },
            {
                _id : 2,
                content: "javascript 복습하기",
                isCompleted: false
            }
        ] 
    */

    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') // => <li data-id="${_id}" class="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_TEXT_SAVE_KEY = 'TODO_TEXT_SAVE_KEY'

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

    this.render = () => {
        $form.innerHTML = `
            <input type='text' placeholder='할 일을 입력하세요.'></input>
            <button type='submit'> 추가하기 </button> 
        `
        
        $form.addEventListener('submit', (e) => {
            e.preventDefault() 

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

            onSubmit(content)

            $input.value = ''

            // 데이터가 추가되면 로컬 스토리지 삭제 
            removeItem(TODO_TEXT_SAVE_KEY)
        })
    }

    this.render()

    // 렌더링 된 이후에 .. 로컬스토리지 처리 
    const $input = $form.querySelector('input')
    $input.value = getItem(TODO_TEXT_SAVE_KEY,'')
    
    $input.addEventListener('keyup', (e)=>{
        setItem(TODO_TEXT_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='newUser' type='text' placeholder='add new User'>
                    </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('.newUser')

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

storage.js

const storage = window.localStorage

export const setItem = (key,value) => {
    try{
        storage.setItem(key,JSON.stringify(value))
    } catch(e){
        console.log(e)
    } 
    /* 브라우저마다 setItem 용량 제한이 있다. 
       용량을 초과해서 데이터를 저장하려고 하면 에러가 발생하므로 
       try catch 처리를 해주는 것이 안전하다. */

}

export const getItem = (key, defaultValue) => {
     // 꺼내오는데 실패 시를 대비해 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 TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"
import UserList from "./userList.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: [],
        isTodoLoading: false
        /*
        [ // todos 형태 
            {
                _id : 1,
                content: "javascript 학습하기",
                isCompleted: true
            },
            {
                _id : 2,
                content: "javascript 복습하기",
                isCompleted: false
            }
        ] 
        */
    }

    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 fetchTodos()
        }
    })

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

    new TodoForm({
        $target: $todoListContainer,
        onSubmit: async(content) => {
            const isFirstTodoAdd = this.state.todos.length === 0

            // todoList에 반영
            // 낙관적 업데이트 (사용성 굿)
            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) 
            })

            // 새 유저에 처음 todo 추가 시 유저리스트에 새 유저 바로 보이게 하기 
            if(isFirstTodoAdd){
                await fetchUserList()
            }

             // todoList에 반영 (낙관적 업데이트가 아닐 때)
             // await fetchTodos()

        }})

    this.setState = (nextState) =>{
        this.state = nextState
        userList.setState(this.state.userList)
        todoList.setState({
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos})
        header.setState({
            username:this.state.selectedUsername,
            isLoading: this.state.isTodoLoading  
        })  
        this.render()    
    } 
    

    // selectedUsername가 없을 땐 $todoListContainer 그리지 않는 처리 
    this.render = () => {
        const { selectedUsername } = this.state
        $todoListContainer.style.display = selectedUsername ? 'block' : 'none'
    }
        
    const todoList = new TodoList({
        $target: $todoListContainer, 
        initialState: {
            isLoading: this.state.isTodoLoading,
            todos: this.state.todos,
        }, 

        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'
            })
        }, 

        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`,{
                method: 'PUT',
            })
        }
    })

    // username 불러오기 
    const fetchUserList = async () => {
        const userList = await request('/users')

        this.setState({
            ...this.state,
            userList: userList
        })
    }


    // username별 todo 목록 불러오기 
    const fetchTodos = async () => {
        const {selectedUsername} = this.state

        // 현재 username이 있을 때만 todo 목록을 불러온다. 
        if(selectedUsername){
            // 데이터를 불러오는 로딩중일 때 
            this.setState({
                ... this.state,
                isTodoLoading: true
            })

            // request
            const todos = await request(`/${selectedUsername}`)
            // 로딩중 확인할 때 `/${selectedUsername}?delay=1000`

            this.setState({
                ...this.state,
                todos,
                isTodoLoading: false // 로딩이 끝나면 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 fetchTodos()
            }
        }   
    }

    window.addEventListener('popstate',(e)=>{
        init()
    })

    this.render()
    init()
}

profile
오늘도 신나개 🐶

0개의 댓글