Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[17팀 정소윤][Chapter 1-1] 프레임워크 없이 SPA 만들기 #9

Closed
wants to merge 44 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b509516
feat: templates html 확장자 js로 변경
soyoonJ Sep 22, 2024
5a87400
feat: main.js History API 활용한 라우터 구현
soyoonJ Sep 22, 2024
c2c7e2f
feat: Login.js 로그인 input required 추가
soyoonJ Sep 22, 2024
4756577
fix: main.js 라우터 로직 수정
soyoonJ Sep 22, 2024
576194e
feat: main.js 로그인 기능 추가
soyoonJ Sep 22, 2024
ac5c0a8
feat: main.js protectedRoute 추가
soyoonJ Sep 22, 2024
b22171b
feat: 로그아웃 기능 추가
soyoonJ Sep 22, 2024
0a6d993
feat: 프로필 조회 및 수정 기능 추가
soyoonJ Sep 22, 2024
61ec7a3
refactor: templates 페이지 파일명 수정
soyoonJ Sep 22, 2024
2dd5567
style: 17팀 prettier 규칙 적용
soyoonJ Sep 22, 2024
2b17dbd
refactor: 인증 관련 useAuth 분리
soyoonJ Sep 22, 2024
a5561bf
refactor: 프로필 관련 useProfile 분리
soyoonJ Sep 22, 2024
117b9c8
refactor: route 관련 useNavigate 분리
soyoonJ Sep 22, 2024
b54575c
refactor: main.js 내 신규 생성 모듈 import
soyoonJ Sep 22, 2024
01fdd6e
refactor: navigate 모듈 생성
soyoonJ Sep 22, 2024
e8ef1e5
refactor: routes.js 경로 파일 생성
soyoonJ Sep 22, 2024
7eb087f
refactor: Home 컴포넌트 생성
soyoonJ Sep 22, 2024
4debd96
refactor: Login 컴포넌트 생성
soyoonJ Sep 22, 2024
0c05461
refactor: NotFound 컴포넌트 생성
soyoonJ Sep 22, 2024
ca02e35
refactor: Profile 컴포넌트 생성
soyoonJ Sep 22, 2024
a3cd34e
refactor: 신규생성 모듈/컴포넌트 import
soyoonJ Sep 22, 2024
ed8e0e4
refactor: 리팩터링 이전 파일 삭제
soyoonJ Sep 22, 2024
9c7f3b1
fix: NotFound 홈으로 돌아가기 SPA routing으로 변경
soyoonJ Sep 22, 2024
92570ab
refactor: a tag eventListener addEvent 함수 안으로 위치 이동
soyoonJ Sep 22, 2024
ac4d7b2
refactor: Header 컴포넌트 생성
soyoonJ Sep 22, 2024
5f80d2c
refactor: Footer 컴포넌트 생성
soyoonJ Sep 22, 2024
e0622c4
refactor: Header, Footer 컴포넌트 구조로 변경
soyoonJ Sep 22, 2024
8353655
refactor: a 태그 및 logout 이벤트 등록 통합
soyoonJ Sep 22, 2024
435ba8c
fix: 네비게이션 선택 메뉴 font-bold 추가
soyoonJ Sep 22, 2024
4f1d4da
refactor: pages render 내부 호출 함수 외부로 호출 위치 변경
soyoonJ Sep 23, 2024
94b36d2
fix: '/' 로그인 유무와 상관없이 접근 가능하도록 수정, 네비게이션 분기처리
soyoonJ Sep 23, 2024
8129a84
feat: 에러 감지 기능 추가
soyoonJ Sep 23, 2024
948cb23
fix: src/pages 컴포넌트명 -Page로 수정
soyoonJ Sep 23, 2024
eb35c60
feature: store 생성
soyoonJ Sep 23, 2024
73a00d8
fix: 사용자 정보 조회/업데이트 store로 관리
soyoonJ Sep 23, 2024
b8fc056
fix: updateHTML 불필요 async 삭제
soyoonJ Sep 23, 2024
d8d851b
fix: store isLoggedIn 상태 추가
soyoonJ Sep 24, 2024
bd25dca
refactor: Header 메뉴 return 구조 수정
soyoonJ Sep 24, 2024
2b835c2
feat: login 사용자 이름 입력 및 검증 로직 추가
soyoonJ Sep 24, 2024
131412f
fix: store listener delete 기능 추가
soyoonJ Sep 24, 2024
7ce0458
feat: 페이지 타이틀 및 파비콘 추가
soyoonJ Sep 24, 2024
a31d266
refactor: import 순서 정리, navigate -> useNavigate 파일명 수정
soyoonJ Sep 24, 2024
71d396f
refactor: route guard useNavigate.js 외부로 이동
soyoonJ Sep 24, 2024
7aed1b0
refactor: 변수명 updateHTML -> renderPage 변경
soyoonJ Sep 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2,
"semi": true
}
24 changes: 13 additions & 11 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>항해플러스 SNS</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>항해플러스 SNS</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" href="./public/favicon.ico" />
</head>
<body>
<div id="root"></div>

<script type="module" src="/src/main.js"></script>
</body>
</html>
Binary file added public/favicon.ico
Binary file not shown.
33 changes: 18 additions & 15 deletions src/__tests__/advanced.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ beforeAll(async () => {
window.alert = vi.fn();
document.body.innerHTML = '<div id="root"></div>';
await import('../main.js');
})
});

afterAll(() => {
// 각 테스트 전에 root 엘리먼트 초기화
Expand All @@ -16,38 +16,37 @@ afterAll(() => {
const goTo = (path) => {
window.history.pushState({}, '', path);
window.dispatchEvent(new Event('popstate'));
}

};

beforeEach(() => {
goTo('/');
document.querySelector('#logout')?.click();
})
});

describe('심화과제 테스트', () => {
let user;

beforeEach(() => {
user = userEvent.setup();
})
});

describe('1. 라우트 가드 구현', () => {
it('비로그인 사용자가 프로필 페이지에 접근시 로그인 페이지로 리다이렉트 한다.', async () => {
goTo('/profile')
goTo('/profile');

expect(document.body.innerHTML).toContain('로그인');
});

it('로그인된 사용자가 로그인 페이지에 접근시 메인 페이지로 리다이렉트 한다.', async () => {
goTo('/login')
goTo('/login');

const loginForm = document.getElementById('login-form');

await user.type(document.getElementById('username'), 'testuser')
await user.type(document.getElementById('username'), 'testuser');

loginForm.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }));

goTo('/login')
goTo('/login');
expect(document.querySelector('nav .text-blue-600.font-bold').innerHTML).toContain('홈');
});
});
Expand All @@ -58,9 +57,9 @@ describe('심화과제 테스트', () => {

const firstTarget = document.querySelector('nav a[href="/login"]');

firstTarget.addEventListener('click', e => e.stopPropagation());
firstTarget.addEventListener('click', (e) => e.stopPropagation());

await user.click(firstTarget)
await user.click(firstTarget);

// 클릭 이벤트 생성 및 트리거
expect(document.body.querySelector('header')).not.toBeFalsy();
Expand All @@ -73,14 +72,18 @@ describe('심화과제 테스트', () => {

const $username = document.querySelector('#username');

$username.addEventListener('input', () => {
throw new Error('의도적인 오류입니다.')
}, { once: true });
$username.addEventListener(
'input',
() => {
throw new Error('의도적인 오류입니다.');
},
{ once: true },
);

await user.type($username, '1');

expect(document.body.innerHTML).toContain('오류 발생!');
expect(document.body.innerHTML).toContain('의도적인 오류입니다.');
})
});
});
});
50 changes: 23 additions & 27 deletions src/__tests__/basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ beforeAll(async () => {
window.alert = vi.fn();
document.body.innerHTML = '<div id="root"></div>';
await import('../main.js');
})
});

afterAll(() => {
// 각 테스트 전에 root 엘리먼트 초기화
Expand All @@ -16,14 +16,14 @@ afterAll(() => {
const goTo = (path) => {
window.history.pushState({}, '', path);
window.dispatchEvent(new Event('popstate'));
}
};

describe('기본과제 테스트', () => {
let user;

beforeEach(() => {
user = userEvent.setup();
})
});

describe('1. 라우팅 구현', () => {
it('"/" 경로로 접근하면 홈 페이지가 렌더링된다', () => {
Expand All @@ -38,32 +38,30 @@ describe('기본과제 테스트', () => {
expect(document.body.innerHTML).toContain('로그인');
});



it('로그인이 되지 않은 상태에서 "/profile" 경로로 접근하면, 로그인 페이지로 리다이렉션 된다.', () => {
// 로그인 상태 시뮬레이션
goTo('/profile')
goTo('/profile');

expect(document.body.innerHTML).toContain('로그인');
});

it('존재하지 않는 경로로 접근하면 404 페이지가 렌더링된다', () => {
goTo('/nonexistent')
goTo('/nonexistent');
expect(document.body.innerHTML).toContain('404');
});
});

describe('2. 사용자 관리 기능', () => {
it('로그인 폼에서 사용자 이름을 입력하고 제출하면 로그인 되고, 로그아웃 버튼 클릭시 로그아웃 된다.', async () => {
goTo('/login')
goTo('/login');

const loginForm = document.getElementById('login-form');

await user.type(document.getElementById('username'), 'testuser')
await user.type(document.getElementById('username'), 'testuser');

loginForm.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }));

expect(localStorage.getItem('user')).toEqual(`{"name":"testuser","email":"","bio":""}`);
expect(localStorage.getItem('user')).toEqual(`{"username":"testuser","email":"","bio":""}`);

const logoutButton = document.getElementById('logout');
logoutButton.click();
Expand All @@ -74,20 +72,20 @@ describe('기본과제 테스트', () => {

describe('3. 프로필 페이지 구현', () => {
beforeEach(async () => {
goTo('/login')
goTo('/login');

const loginForm = document.getElementById('login-form');

await user.type(document.getElementById('username'), 'testuser')
await user.type(document.getElementById('username'), 'testuser');

loginForm.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }));

goTo('/profile')
goTo('/profile');
});

afterEach(() => {
document.querySelector('#logout').click();
})
});

it('로그인한 사용자의 이름과 소개가 표시된다', () => {
expect(document.getElementById('username').value).toBe('testuser');
Expand All @@ -102,18 +100,17 @@ describe('기본과제 테스트', () => {
bioInput.value = 'Updated bio';
profileForm.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }));

expect(localStorage.getItem('user')).toEqual(`{"name":"testuser","email":"","bio":"Updated bio","username":"testuser"}`);
expect(localStorage.getItem('user')).toEqual(`{"username":"testuser","email":"","bio":"Updated bio"}`);
});
});

describe('4. 컴포넌트 기반 구조 설계', () => {

beforeEach(async () => {
goTo('/login')
goTo('/login');

const loginForm = document.getElementById('login-form');

await user.type(document.getElementById('username'), 'testuser')
await user.type(document.getElementById('username'), 'testuser');

loginForm.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }));

Expand All @@ -122,25 +119,25 @@ describe('기본과제 테스트', () => {
});

it('Header, Footer 컴포넌트가 메인 페이지와 프로필 페이지에 존재하고, 로그인페이지와 에러페이지에는 존재하지 않는다.', async () => {
goTo('/')
goTo('/');
expect(document.querySelector('header')).not.toBeFalsy();
expect(document.querySelector('footer')).not.toBeFalsy();
expect(document.querySelector('nav')).not.toBeFalsy();

goTo('/profile')
goTo('/profile');
expect(document.querySelector('header')).not.toBeFalsy();
expect(document.querySelector('footer')).not.toBeFalsy();
expect(document.querySelector('nav')).not.toBeFalsy();

goTo('/404')
goTo('/404');
expect(document.querySelector('header')).toBeFalsy();
expect(document.querySelector('footer')).toBeFalsy();
expect(document.querySelector('nav')).toBeFalsy();

goTo('/')
await user.click(document.querySelector('#logout'))
goTo('/');
await user.click(document.querySelector('#logout'));

goTo('/login')
goTo('/login');
expect(document.querySelector('header')).toBeFalsy();
expect(document.querySelector('footer')).toBeFalsy();
expect(document.querySelector('nav')).toBeFalsy();
Expand All @@ -153,15 +150,14 @@ describe('기본과제 테스트', () => {
expect(document.body.innerHTML).toContain('로그인');

// 로그인
goTo('/login')
goTo('/login');

const loginForm = document.getElementById('login-form');

await user.type(document.getElementById('username'), 'testuser')
await user.type(document.getElementById('username'), 'testuser');

loginForm.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }));


// 로그인 상태
expect(document.body.innerHTML).toContain('로그아웃');
});
Expand Down
7 changes: 7 additions & 0 deletions src/components/Footer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const Footer = () => {
return /* HTML */ `<footer class="bg-gray-200 p-4 text-center">
<p>&copy; 2024 항해플러스. All rights reserved.</p>
</footer>`;
};

export default Footer();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

소윤님 Footer처럼 함수를 실행해서 export하는 케이스와 Header처럼 실행하지 않고 export하는 두 가지 케이스가 있더라구요!
혹시 내부에서 상태 관리를 하지 않는 컴포넌트는 바로 실행해서 export 하도록 의도하신 걸까요?!

제 생각에는 나중에 Footer 함수에 상태 관리 로직이 추가될 경우 내부에 render 함수를 구현해야 될 것 같은데,
"컴포넌트의 구조가 통일되는 것이 나중을 위해 더 좋지 않을까..?"하고 생각하는데 소윤님 생각은 어떤지 궁금합니다!

함수형 컴포넌트라 이 부분이 더 어려웠을 것 같네요 😥

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

승우님! 리뷰 감사합니다 :)
말씀주신 것처럼 내부에서 상태 관리를 하지 않는 컴포넌트는 바로 실행해서 export 하려는 의도가 맞습니다!

하지만 승우님 말씀 들어보니 컴포넌트는 현재 렌더링을 하든 하지 않든 통일성을 맞추는 게 좋을 것 같네요!
좋은 의견 감사합니다 👍👍

38 changes: 38 additions & 0 deletions src/components/Header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { store } from '../store';

const Header = (currentPath) => {
const isLoggedIn = store.getState('isLoggedIn');

const menus = [
Copy link

@kyh196201 kyh196201 Sep 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

active 속성을 추가해서 렌더링 여부 결정하도록 로직 작성하신거 너무 좋은 것 같아요
전 로그인 여부에 따라 배열을 필터링하는 방법만 떠올랐는데..
덕분에 새로운 아이디어 얻어갑니다 😁😁

{ name: '홈', path: '/', active: true },
{ name: '프로필', path: '/profile', active: isLoggedIn },
{ name: '로그인', path: '/login', active: !isLoggedIn },
];

const render = () => {
return /* HTML */ `<header class="bg-blue-600 text-white p-4 sticky top-0">
<h1 class="text-2xl font-bold">항해플러스</h1>
</header>

<nav class="bg-white shadow-md p-2 sticky top-14">
<ul class="flex justify-around">
${menus
.map((menu) => {
if (!menu.active) return '';
return /* HTML */ `<li>
<a href=${menu.path} class="${currentPath === menu.path ? 'text-blue-600 font-bold' : 'text-gray-600'}"
>${menu.name}</a
>
</li>`;
})
.join('')}
Comment on lines +19 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

menus.filter(v => v.active).map()

이렇게 미리 filter로 걸러주면 어떨까요!?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

menus.filter(v => v.active).map()

이렇게 미리 filter로 걸러주면 어떨까요!?

안 그래도 이 부분이 고민이 되던 부분인데요! menues.filter().map()을 할 경우 loop가 두 번 돌아야 한다는 점이 걸려, map으로 한 바퀴만 돌면서 처리하고자 했습니다. 사실 메뉴 수가 많지 않아 크게 영향을 받을 부분은 아니지만 loop를 최소화 해야겠다는 생각이 마음 한 자리에 자리잡고 있어서요!🤣

이런 이유에도 처음에 filter랑 같이 쓰는 것을 고민했던 이유는 가독성 때문이었는데요, 코치님께서 filter로 처리하는 것을 제안주신 이유는 무엇일지 궁금합니다 :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅎㅎ 이정도의 차이는 성능에 대한 이점을 가져간다거나 그런게 딱히 없어서요!
차라리 가독성을 챙기는게 좋다고 생각합니다.
무엇보다 어차피 연산하는 횟수는 비슷할꺼에요!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상황에 따라 판단하는 게 좋겠군요! 자세한 공유 감사합니다 :)

${isLoggedIn ? '<li><button id="logout" class="text-gray-600">로그아웃</button></li>' : ''}
</ul>
</nav>`;
};

let headerHTML = render();
return headerHTML;
};

export default Header;
Loading
Loading