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
+
+
+
+
+
+
+
+
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}${tagName}>` : '';
+
+ 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');
+ });
+});