diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/getTodos.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/getTodos.js" new file mode 100644 index 0000000..2dc43b6 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/getTodos.js" @@ -0,0 +1,19 @@ +const { faker } = window; + +const createElement = () => ({ + text: faker.random.words(2), + completed: faker.random.boolean(), +}); + +const repeat = (elementFactory, number) => { + const array = []; + for (let index = 0; index < number; index += 1) { + array.push(elementFactory()); + } + return array; +}; + +export default () => { + const howMany = faker.random.number(10); + return repeat(createElement, howMany); +}; diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/index.html" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/index.html" new file mode 100644 index 0000000..5fc15a3 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/index.html" @@ -0,0 +1,44 @@ + + + + + + + Frameworkless Frontend Development: Rendering + + + +
+
+

todos

+ +
+
+ + + +
+ +
+ + + + diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/index.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/index.js" new file mode 100644 index 0000000..d6d455e --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/index.js" @@ -0,0 +1,14 @@ +import getTodos from './getTodos.js'; +import appView from './view/app.js'; + +const state = { + todos: getTodos(), + currentFilter: 'All', +}; + +const main = document.querySelector('.todoapp'); + +window.requestAnimationFrame(() => { + const newMain = appView(main, state); + main.replaceWith(newMain); +}); diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/app.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/app.js" new file mode 100644 index 0000000..8c09e75 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/app.js" @@ -0,0 +1,17 @@ +import todosView from './todos.js'; +import counterView from './counter.js'; +import filtersView from './filters.js'; + +export default (targetElement, state) => { + const element = targetElement.cloneNode(true); + + const list = element.querySelector('.todo-list'); + const counter = element.querySelector('.todo-count'); + const filters = element.querySelector('.filters'); + + list.replaceWith(todosView(list, state)); + counter.replaceWith(counterView(counter, state)); + filters.replaceWith(filtersView(filters, state)); + + return element; +}; diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/counter.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/counter.js" new file mode 100644 index 0000000..09b081d --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/counter.js" @@ -0,0 +1,16 @@ +const getNotCompleteCount = (todos) => todos.filter((todo) => !todo.completed).length; +const getNotCompleteTextContent = (todos) => { + const count = getNotCompleteCount(todos); + if (count === 0) return 'No item left'; + if (count === 1) return '1 Item left'; + return `${count} Items left`; +}; + +export default (targetElement, state) => { + const { todos } = state; + + const newElement = targetElement.cloneNode(true); + newElement.textContent = getNotCompleteTextContent(todos); + + return newElement; +}; diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/counter.test.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/counter.test.js" new file mode 100644 index 0000000..1088132 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/counter.test.js" @@ -0,0 +1,64 @@ +import counterView from './counter'; + +let targetElement; + +describe('counterView', () => { + beforeEach(() => { + targetElement = document.createElement('div'); + }); + + test('새로운 DOM 요소는 완료되지 않은 todo의 수를 가지고 있어야 한다.', () => { + const newCounter = counterView(targetElement, { + todos: [ + { + text: 'First', + completed: true, + }, + { + text: 'Second', + completed: false, + }, + { + text: 'Third', + completed: false, + }, + ], + }); + + expect(newCounter.textContent).toBe('2 Items left'); + }); + + test('완료하지 않은 todo가 1개일 경우를 고려해야 한다.', () => { + const newCounter = counterView(targetElement, { + todos: [ + { + text: 'First', + completed: true, + }, + { + text: 'Third', + completed: false, + }, + ], + }); + + expect(newCounter.textContent).toBe('1 Item left'); + }); + + test('전부 완료했을 때의 경우도 고려해야 한다.', () => { + const newCounter = counterView(targetElement, { + todos: [ + { + text: 'First', + completed: true, + }, + { + text: 'Third', + completed: true, + }, + ], + }); + + expect(newCounter.textContent).toBe('No item left'); + }); +}); diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/filters.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/filters.js" new file mode 100644 index 0000000..30b5f38 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/filters.js" @@ -0,0 +1,20 @@ +const getAllListAnchorsFrom = (element) => [...element.querySelectorAll('li a')]; +const setSelectedClassNameToAnchors = (anchors, currentFilter) => { + anchors.forEach((anchor) => { + if (anchor.textContent === currentFilter) { + anchor.classList.add('selected'); + } else { + anchor.classList.remove('selected'); + } + }); +}; + +export default (targetElement, state) => { + const { currentFilter } = state; + + const newElement = targetElement.cloneNode(true); + const anchors = getAllListAnchorsFrom(newElement); + setSelectedClassNameToAnchors(anchors, currentFilter); + + return newElement; +}; diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/filters.test.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/filters.test.js" new file mode 100644 index 0000000..b9f507c --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/filters.test.js" @@ -0,0 +1,32 @@ +import filtersView from './filters'; + +let targetElement; +const TEMPLATE = ``; + +describe('filtersView', () => { + beforeEach(() => { + const tempElement = document.createElement('div'); + tempElement.innerHTML = TEMPLATE; + [targetElement] = tempElement.childNodes; + }); + + test('"currentFilter"와 동일한 텍스트를 가지는 anchor 태그에 "selected" 클래스를 추가해야 한다.', () => { + const newCounter = filtersView(targetElement, { + currentFilter: 'Active', + }); + + const selectedItem = newCounter.querySelector('li a.selected'); + + expect(selectedItem.textContent).toBe('Active'); + }); +}); diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/todos.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/todos.js" new file mode 100644 index 0000000..805c333 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/todos.js" @@ -0,0 +1,44 @@ +import { createInnerHTML } from './utils.js'; + +const getTodoItemElement = ({ text, completed }) => { + const toggle = createInnerHTML('input', { + attributes: { + class: 'toggle', + type: 'checkbox', + checked: completed, + }, + }); + const label = createInnerHTML('label', { + innerHTML: text, + }); + const edit = createInnerHTML('input', { + attributes: { + class: 'edit', + value: text, + }, + }); + const viewBox = createInnerHTML('div', { + attributes: { + class: 'view', + }, + innerHTML: toggle + label, + }); + + return createInnerHTML('li', { + attributes: { + class: completed ? 'completed' : '', + }, + innerHTML: `${viewBox}${edit}`, + }); +}; + +export default (targetElement, state) => { + const { todos } = state; + + const innerHTML = todos.map((todo) => getTodoItemElement(todo)).join(''); + + const newElement = targetElement.cloneNode(true); + newElement.innerHTML = innerHTML; + + return newElement; +}; diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/todos.test.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/todos.test.js" new file mode 100644 index 0000000..5959157 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/todos.test.js" @@ -0,0 +1,58 @@ +import todosView from './todos'; + +let targetElement; + +describe('filtersView', () => { + beforeEach(() => { + targetElement = document.createElement('ul'); + }); + + test('모든 todo 요소에 대해서 li 태그를 생성해야 한다.', () => { + const newCounter = todosView(targetElement, { + todos: [ + { + text: 'First', + completed: true, + }, + { + text: 'Second', + completed: false, + }, + { + text: 'Third', + completed: false, + }, + ], + }); + + const items = newCounter.querySelectorAll('li'); + expect(items.length).toBe(3); + }); + + test('"todos"에 따라 모든 li 요소에 올바른 속성을 설정해야 한다.', () => { + const newCounter = todosView(targetElement, { + todos: [ + { + text: 'First', + completed: true, + }, + { + text: 'Second', + completed: false, + }, + ], + }); + + const [firstItem, secondItem] = newCounter.querySelectorAll('li'); + + expect(firstItem.classList.contains('completed')).toBe(true); + expect(firstItem.querySelector('.toggle').checked).toBe(true); + expect(firstItem.querySelector('label').textContent).toBe('First'); + expect(firstItem.querySelector('.edit').value).toBe('First'); + + expect(secondItem.classList.contains('completed')).toBe(false); + expect(secondItem.querySelector('.toggle').checked).toBe(false); + expect(secondItem.querySelector('label').textContent).toBe('Second'); + expect(secondItem.querySelector('.edit').value).toBe('Second'); + }); +}); diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/utils.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/utils.js" new file mode 100644 index 0000000..aca15de --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/utils.js" @@ -0,0 +1,13 @@ +export const createInnerHTML = (tagName, { attributes = {}, innerHTML = '' } = {}) => { + const arrtibuteString = Object.entries(attributes) + .map(([name, value]) => { + if (typeof value === 'boolean') return value ? name : ''; + return `${name}="${value}"`; + }) + .join(' '); + const closeTagString = innerHTML !== '' ? `${innerHTML}` : ''; + + return `<${tagName} ${arrtibuteString}>${closeTagString}`; +}; + +export default {}; diff --git "a/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/utils.test.js" "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/utils.test.js" new file mode 100644 index 0000000..ff2bdc2 --- /dev/null +++ "b/02. \353\240\214\353\215\224\353\247\201/02/fecapark/view/utils.test.js" @@ -0,0 +1,67 @@ +import { createInnerHTML } from './utils'; + +describe('유틸리티 함수 테스트', () => { + let container; + + beforeEach(() => { + container = document.createElement('div'); + }); + + test('createInnerHTML: 빈 태그를 잘 생성하는가?', () => { + const tagNames = ['div', 'input', 'span', 'a', 'ul', 'li']; + + tagNames.forEach((tagName) => { + container.innerHTML = createInnerHTML(tagName); + const testTarget = container.querySelector(tagName); + + expect(testTarget.tagName).toBe(tagName.toUpperCase()); + expect(testTarget.innerHTML).toBe(''); + }); + }); + + test('createInnerHTML: 속성을 잘 할당하는가?', () => { + container.innerHTML = createInnerHTML('input', { + attributes: { + class: 'test-input', + type: 'checkbox', + checked: true, + }, + }); + const testTarget = container.querySelector('input'); + + expect(testTarget.className).toBe('test-input'); + expect(testTarget.type).toBe('checkbox'); + expect(testTarget.checked).toBe(true); + }); + + test('createInnerHTML: innerHTML을 잘 할당하는가?', () => { + const html = [ + createInnerHTML('div', { + attributes: { + class: 'inner-div', + }, + innerHTML: 'hello', + }), + createInnerHTML('input', { + attributes: { + class: 'inner-input', + type: 'number', + value: '1', + }, + }), + ].join(''); + + container.innerHTML = createInnerHTML('div', { + attributes: { + class: 'test-container', + }, + innerHTML: html, + }); + const testTarget = container.querySelector('.test-container'); + const innerDiv = testTarget.querySelector('.inner-div'); + const innerInput = testTarget.querySelector('.inner-input'); + + expect(innerDiv.textContent).toBe('hello'); + expect(innerInput.value).toBe('1'); + }); +});