From e299419ef93ad5ac8fffe5d51696766da57ab22e Mon Sep 17 00:00:00 2001 From: 2yunseong <56749516+2yunseong@users.noreply.github.com> Date: Tue, 14 Mar 2023 07:10:00 +0900 Subject: [PATCH] =?UTF-8?q?[2=EB=8B=A8=EA=B3=84=20-=20=EC=9D=8C=EC=8B=9D?= =?UTF-8?q?=EC=A0=90=20=EB=AA=A9=EB=A1=9D]=20=EC=9C=A4=EC=83=9D(=EC=9D=B4?= =?UTF-8?q?=EC=9C=A4=EC=84=B1)=20=EB=AF=B8=EC=85=98=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 요구사항 목록 갱신 * refactor: Component Interface 변경 - render와 setState를 강제하지 않음 * feat: 탭바 UI 토글 기능 추가 * feat: 탭 바 메뉴별 정렬 로직 구현 * feat: 자주 가는 음식점 버튼 토글 기능 구현 및 저장소에 요청 * fix: 좋아요 상태 변경 시 리렌더링 로직 추가 * feat: Drawer 좋아요 토글 동작 구현 * refactor: 이벤트 리스너 등록 로직 메서드로 묶음 * refactor: export default 축약 * refactor: 사용성 개선 * docs: 요구사항 최신화 및 e2e테스트 기능 명세 작성 * test: 음식점 추가 버튼 클릭 테스트 * test: 음식점 추가 창 취소하기 버튼 테스트 * test: 음식 종류 별 정렬 기능 테스트 * refactor: 의미에 맞지 않는 html 태그 변경 및 class name 할당 * test: 음식점 이름/거리 순 정렬 기능 테스트 * test: 탭바 테스트 * test: 음식정 상세 정보 창 테스트 * refactor: 이벤트 핸들러 이름 일관성 부여 * refactor: 불필요한 setState 제거 * refactor: 컴포넌트에 불필한 로직 분리 * refactor: 메서드 분리 * refactor: 비구조화 할당 제거 * refactor: 디렉터리 구조 변경 * test: 음식점 목록 자주가는 음식점 토글 기능 테스트 * docs: 요구사항 목록 최신화 * feat: 고유한 아이디를 생성하게 구현 * refactor: 레스토랑 등록 로직 분리 * refactor: 사용하지 않는 import 제거 * refactor: 재사용 되는 로직 분리 * refactor: img src 소스 링크 상수화 * test: 코드 중복 제거 * test: 테스트 이름 변경 * refactor: 축약된 이름 변경 * refactor: 불필요한 리렌더링 제거 * refactor: 이벤트 위임으로 핸들러 일반화 * refactor: 이벤트 위임으로 변경 * refactor: DropDown 이벤트 핸들러 메서드 로직 병합 * refactor: 접근 제한자 부여 * refactor: drawer html 분리 --- __tests__/render.test.js | 6 +- cypress.config.ts | 9 + cypress/data.js | 151 ++++++++++++++++ cypress/e2e/app.cy.js | 171 ++++++++++++++++++ cypress/fixtures/example.json | 5 + cypress/support/commands.ts | 37 ++++ cypress/support/e2e.ts | 20 ++ docs/REQUIREMENTS.md | 118 +++--------- src/App.ts | 31 ++-- src/components/DropDown.ts | 20 +- src/components/FormItem.ts | 14 ++ src/components/GNB.ts | 25 ++- src/components/RestaurantListItem.ts | 49 ++++- src/components/TabBar.ts | 46 +++++ src/css/index.css | 67 ++++++- src/index.js | 2 + src/interface.ts | 4 +- src/pages/AddRestaurantDrawer/index.ts | 139 ++++++-------- .../AddRestaurantDrawer/template/category.ts | 14 ++ .../template/description.ts | 9 + .../AddRestaurantDrawer/template/distance.ts | 15 ++ .../AddRestaurantDrawer/template/link.ts | 9 + .../AddRestaurantDrawer/template/name.ts | 8 + src/pages/RestaurantInfoDrawer/index.ts | 96 ++++++++++ .../RestaurantFilterContainer.ts | 36 ++-- .../RestaurantListPage/RestaurantList.ts | 72 ++++++-- src/pages/RestaurantListPage/index.ts | 97 +++++++--- src/type.ts | 3 + src/utils/api.js | 49 +++++ src/utils/constants.ts | 3 + src/utils/util.js | 5 + 31 files changed, 1024 insertions(+), 306 deletions(-) create mode 100644 cypress.config.ts create mode 100644 cypress/data.js create mode 100644 cypress/e2e/app.cy.js create mode 100644 cypress/fixtures/example.json create mode 100644 cypress/support/commands.ts create mode 100644 cypress/support/e2e.ts create mode 100644 src/components/FormItem.ts create mode 100644 src/components/TabBar.ts create mode 100644 src/pages/AddRestaurantDrawer/template/category.ts create mode 100644 src/pages/AddRestaurantDrawer/template/description.ts create mode 100644 src/pages/AddRestaurantDrawer/template/distance.ts create mode 100644 src/pages/AddRestaurantDrawer/template/link.ts create mode 100644 src/pages/AddRestaurantDrawer/template/name.ts create mode 100644 src/pages/RestaurantInfoDrawer/index.ts create mode 100644 src/utils/api.js create mode 100644 src/utils/util.js diff --git a/__tests__/render.test.js b/__tests__/render.test.js index 93b56bb51..5ef470946 100644 --- a/__tests__/render.test.js +++ b/__tests__/render.test.js @@ -73,7 +73,7 @@ describe('컴포넌트 랜더링 테스트', () => { describe('GNB 테스트', () => { test('GNB를 화면에 잘 나타내는지 테스트', () => { - new GNB({ $parent: document.body, toggleAddRestaurantDrawer: () => {} }).render(); + new GNB({ $parent: document.body, onToggleAddRestaurantDrawer: () => {} }).render(); expect(screen.getByText('점심 뭐 먹지')).toBeInTheDocument(); }); }); @@ -82,7 +82,7 @@ describe('컴포넌트 랜더링 테스트', () => { test('select 태그 렌더링 테스트: 카테고리', () => { new AddRestaurantDrawer({ $parent: document.body, - toggleAddRestaurantDrawer: () => {}, + onToggleAddRestaurantDrawer: () => {}, }).render(); const categoryElement = document.getElementById('category'); const categories = ['한식', '중식', '일식', '양식', '아시안', '기타']; @@ -94,7 +94,7 @@ describe('컴포넌트 랜더링 테스트', () => { test('select 태그 렌더링 테스트: 거리(도보 x분)', () => { new AddRestaurantDrawer({ $parent: document.body, - toggleAddRestaurantDrawer: () => {}, + onToggleAddRestaurantDrawer: () => {}, }).render(); const distanceElement = document.getElementById('distance'); const distances = [5, 10, 15, 20, 30]; diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..17161e32e --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + e2e: { + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/cypress/data.js b/cypress/data.js new file mode 100644 index 000000000..77812002c --- /dev/null +++ b/cypress/data.js @@ -0,0 +1,151 @@ +export const CATEGORY_TEST_CASE = [ + { + id: 0, + category: '한식', + name: '가_한식', + distance: '30', + description: '가_한식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 1, + category: '한식', + name: '나_한식', + distance: '15', + description: '나_한식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 2, + category: '중식', + name: '다_중식', + distance: '10', + description: '다_중식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 3, + category: '일식', + name: '라_일식', + distance: '5', + description: '라_일식입니다', + link: '', + isFavorite: false, + }, + { + id: 4, + category: '양식', + name: '하_오분거리 양식', + distance: '5', + description: '5분거리', + link: '', + isFavorite: false, + }, + { + id: 5, + category: '아시안', + name: '하_오분거리 아시안', + distance: '5', + description: '5분거리', + link: '', + isFavorite: false, + }, +]; + +export const SORTING_TEST_CASE = [ + { + id: 0, + category: '한식', + name: '다섯번째 거리', + distance: '30', + description: '한식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 1, + category: '한식', + name: '세번째 거리', + distance: '15', + description: '한식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 2, + category: '한식', + name: '두번째 거리', + distance: '10', + description: '한식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 3, + category: '한식', + name: '네번째 거리', + distance: '20', + description: '한식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 4, + category: '한식', + name: '첫번째 거리', + distance: '5', + description: '한식입니다.', + link: 'http://www.naver.com', + isFavorite: false, + }, +]; + +export const FAVORITES_TEST_CASE = [ + { + id: 0, + category: '한식', + name: '매우_좋아요', + distance: '5', + description: 'good', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 1, + category: '한식', + name: '안좋아요', + distance: '5', + description: 'good', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 2, + category: '한식', + name: '안좋아요', + distance: '5', + description: 'good', + link: 'http://www.naver.com', + isFavorite: false, + }, + { + id: 3, + category: '한식', + name: '안좋아요', + distance: '5', + description: 'good', + link: 'http://www.naver.com', + isFavorite: false, + }, +]; + +export const RESTAURANT_INPUT_CASE = { + category: '한식', + name: '얌샘김밥', + distance: '5', + description: 'good', + link: 'http://www.naver.com', +}; diff --git a/cypress/e2e/app.cy.js b/cypress/e2e/app.cy.js new file mode 100644 index 000000000..b22f2811a --- /dev/null +++ b/cypress/e2e/app.cy.js @@ -0,0 +1,171 @@ +import { + CATEGORY_TEST_CASE, + FAVORITES_TEST_CASE, + SORTING_TEST_CASE, + RESTAURANT_INPUT_CASE, +} from '../data'; + +const TEST_URL = 'http://localhost:8080/'; + +describe('음식점 추가 창', () => { + beforeEach('beforeEach', () => { + cy.visit(TEST_URL); + }); + + it('음식점 추가버튼(우측 상단)을 클릭하면 음식점 추가창을 볼 수 있다.', () => { + cy.get('.gnb__button').click(); + cy.contains('새로운 음식점'); + }); + + it('음식점 추가 창에서 취소하기 버튼을 클릭하면 이전 화면으로 돌아간다.', () => { + cy.get('.gnb__button').click(); + cy.contains('새로운 음식점'); + cy.get('#modal-cancel').click(); + cy.get('.modal').should('not.exist'); + }); + + it('카테고리, 이름, 거리, 설명, 링크 `입력창`을 작성하고 `추가하기 버튼`을 `클릭`하면 음식점이 목록에 추가된다. (음식점 목록 추가)', () => { + cy.get('.gnb__button').click(); + cy.contains('새로운 음식점'); + cy.get('#category').select(RESTAURANT_INPUT_CASE.category); + cy.get('#name').type(RESTAURANT_INPUT_CASE.name); + cy.get('#distance').select(RESTAURANT_INPUT_CASE.distance); + cy.get('#description').type(RESTAURANT_INPUT_CASE.description); + cy.get('#link').type(RESTAURANT_INPUT_CASE.link); + cy.get('.button-container').contains('추가하기').click(); + cy.get('.app').contains('5분 내'); + }); +}); + +describe('음식점 목록, 드롭다운 메뉴/탭바', () => { + it('드롭다운 메뉴를 통해 음식점 목록을 음식 종류 별로 정렬할 수 있다.', () => { + cy.visit(TEST_URL, { + onBeforeLoad(win) { + win.localStorage.setItem('restaurants', JSON.stringify(CATEGORY_TEST_CASE)); + }, + }); + cy.get('#category-filter').select('한식'); + cy.get('.app').should('contain.text', '가_한식'); + cy.get('.app').should('contain.text', '가_한식'); + cy.get('.app').contains('다_중식').should('not.exist'); + cy.get('.app').contains('라_일식').should('not.exist'); + cy.get('.app').contains('하_오분거리 양식').should('not.exist'); + cy.get('.app').contains('하_오분거리 아시안').should('not.exist'); + }); + + it('드롭다운 메뉴를 통해 음식점 목록을 이름/거리 순으로 정렬할 수 있다.', () => { + cy.visit(TEST_URL, { + onBeforeLoad(win) { + win.localStorage.setItem('restaurants', JSON.stringify(SORTING_TEST_CASE)); + }, + }); + cy.get('#sorting-filter').select('distance'); + cy.get('.restaurant-list li.restaurant') + .first() + .should('contain', '첫번째 거리') + .next() + .should('contain', '두번째 거리') + .next() + .should('contain', '세번째 거리') + .next() + .should('contain', '네번째 거리') + .next() + .should('contain', '다섯번째 거리'); + + cy.get('#sorting-filter').select('name'); + cy.get('.restaurant-list li.restaurant') + .first() + .should('contain', '네번째 거리') + .next() + .should('contain', '다섯번째 거리') + .next() + .should('contain', '두번째 거리') + .next() + .should('contain', '세번째 거리') + .next() + .should('contain', '첫번째 거리'); + }); + + it('탭바를 통해 음식점 목록을 모든 음식점/자주 가는 음식점 으로 분류하여 볼 수 있다.', () => { + cy.visit(TEST_URL, { + onBeforeLoad(win) { + win.localStorage.setItem('restaurants', JSON.stringify(FAVORITES_TEST_CASE)); + }, + }); + cy.get('.restaurant-list li.restaurant .favorite__button').first().click(); + cy.get('.tab-bar-select[data-type="favorite"]').click(); + cy.get('.app').contains('매우_좋아요'); + cy.get('.app').contains('안좋아요').should('not.exist'); + }); + + it('음식점 목록에서 `별표 버튼`을 `클릭`해 자주 가는 음식점으로 `등록/해제` 할 수 있다.', () => { + cy.visit(TEST_URL, { + onBeforeLoad(win) { + win.localStorage.setItem('restaurants', JSON.stringify(FAVORITES_TEST_CASE)); + }, + }); + cy.get('.restaurant-list li.restaurant .favorite__button').first().click(); + cy.get('.favorite__button>img') + .first() + .should('have.attr', 'src', `./favorite-icon-filled.png`); + }); +}); + +describe('음식점 상세 정보 창', () => { + it('음식점 목록을 클릭해 음식점 상세 정보창을 열 수 있다.', () => { + cy.visit(TEST_URL, { + onBeforeLoad(win) { + win.localStorage.setItem('restaurants', JSON.stringify(FAVORITES_TEST_CASE)); + }, + }); + cy.get('.restaurant-list li.restaurant').first().click(); + cy.get('.modal').contains(FAVORITES_TEST_CASE[0].name); + cy.get('.modal').contains(FAVORITES_TEST_CASE[0].description); + cy.get('.modal').contains(FAVORITES_TEST_CASE[0].link); + }); + + it('음식점 상세 정보창에서 `별표 버튼`을 `클릭`해 자주 가는 음식점으로 `등록/해제` 할 수 있다.', () => { + cy.visit(TEST_URL, { + onBeforeLoad(win) { + win.localStorage.setItem('restaurants', JSON.stringify(FAVORITES_TEST_CASE)); + }, + }); + + cy.get('.restaurant-list li.restaurant').first().click(); + cy.get('.modal .favorite__button').click(); + cy.get('.modal #drawer-close__button').click(); + + cy.get('.tab-bar-select[data-type="favorite"]').click(); + cy.get('.app').contains('매우_좋아요'); + }); + + it('음식점 상세 정보창에서 `닫기 버튼`을 `클릭`해 음식점 상세 정보 창을 닫을 수 있다.', () => { + cy.visit(TEST_URL, { + onBeforeLoad(win) { + win.localStorage.setItem('restaurants', JSON.stringify(FAVORITES_TEST_CASE)); + }, + }); + + cy.get('.restaurant-list li.restaurant').first().click(); + cy.get('.modal #drawer-close__button').click(); + cy.get('.modal').should('not.exist'); + }); + + it('음식점 상세 정보창에서 `삭제 버튼`을 `클릭`해 음식점을 목록에서 삭제할 수 있다.', () => { + cy.visit(TEST_URL); + // 음식점 추가 + cy.get('.gnb__button').click(); + cy.contains('새로운 음식점'); + cy.get('#category').select(RESTAURANT_INPUT_CASE.category); + cy.get('#name').type(RESTAURANT_INPUT_CASE.name); + cy.get('#distance').select(RESTAURANT_INPUT_CASE.distance); + cy.get('#description').type(RESTAURANT_INPUT_CASE.description); + cy.get('#link').type(RESTAURANT_INPUT_CASE.link); + cy.get('.button-container').contains('추가하기').click(); + + // 삭제 버튼 + cy.get('.restaurant-list li.restaurant').first().click(); + cy.get('#restaurant-delete__button').click(); + cy.get('.app').contains(RESTAURANT_INPUT_CASE.name).should('not.exist'); + }); +}); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 000000000..02e425437 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 000000000..698b01a42 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,37 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } \ No newline at end of file diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 000000000..f80f74f8e --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') \ No newline at end of file diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index a990b0a0a..76fdd3a34 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -1,97 +1,25 @@ # 구현할 기능 목록 -## 컴포넌트 구조 - -``` -앱 -├─ 메인 템플릿 -│ ├─ 헤더 -│ ├─ 음식점 리스트 페이지 -│ ├─ 음식점 리스트 헤더 -│ │ ├─ 카테고리 -│ │ ├─ 정렬 -│ ├─ 음식점 리스트 -│ ├─ 음식점 -│ ├─ 사진 -│ ├─ 이름 -│ ├─ 거리 -│ ├─ 설명 -├─ 모달 - ├─ 폼 - ├─ 헤더 - ├─ 여러가지 인풋 - ├─ 취소/추가 버튼 -``` - -## 컴포넌트 상세 - -### 앱 - -- 상태 - - 1. 모달 숨김 - -- 핸들러 - - 1. 모달 여닫는 기능 - -### 메인 - -- 상태 - - 1. 모달 숨김 - -- 핸들러 - - 1. 모달 여닫는 기능 - -### 음식점 리스트 페이지 - -- 상태 - - 1. 현재 볼 카테고리 - 2. 정렬정보 (이름, 설명, 참고) - 3. 음식점 정보 리스트 - -- 핸들러 - - 1. 카테고리 핸들러 - 2. 정렬정보 핸들러 - -### 음식점 리스트 헤더 - -- 상태 - - 1. 현재 볼 카테고리 - 2. 정렬정보 (이름, 설명, 참고) - -- 핸들러 - - 1. 카테고리 핸들러 - 2. 정렬정보 핸들러 - -### 음식점 리스트 - -- 상태 - - 1. 음식점 정보 리스트 - -### 음식점 - -- 상태 - - 1. 음식점 정보 - -### 모달 - -- 상태 - - 1. 모달 숨김 - 2. 음식점 정보 - -- 핸들러 - - 1. 모달 여닫는 기능 - 2. 음식점 추가 - -## 유틸 +## 기능 요구사항 + +- [x] 음식점 리스트에서 자주가는 음식점으로 선택할 수 있다. +- [x] 음식점 리스트에서 음식점 클릭시 Drawer 가 열린다. +- [x] 음식점 Drawer에서 삭제버튼을 누르면 음식점이 리스트에서 삭제된다. +- [x] 음식점 Drawer에서 닫기버튼을 누르면 음식점이 리스트를 닫는다. +- [x] 음식점 Drawer에서 자주가는 음식점을 등록할 수 있다. +- [x] 사용자가 입력한 링크가 유효한지 판단한다. (AddRestaurantDrawer) +- [x] 탭 바를 통해 '모든 음식점'과 '지주가는 음식점'을 필터링 할 수 있다. + +## 테스트 기능 명세 - 유저 관점 + +- [x] `음식점 추가버튼(우측 상단)`을 `클릭`하면 음식점 추가창을 볼 수 있다. +- [x] 음식점 추가 창에서 `취소하기 버튼`을 `클릭`하면 이전 화면으로 돌아간다. +- [x] 카테고리, 이름, 거리, 설명, 링크 `입력창`을 작성하고 `추가하기 버튼`을 `클릭`하면 음식점이 목록에 추가된다. (음식점 목록 추가) +- [x] `드롭다운 메뉴`를 통해 음식점 목록을 음식 종류 별로 정렬할 수 있다. +- [x] `드롭다운 메뉴`를 통해 음식점 목록을 이름/거리 순으로 정렬할 수 있다. +- [x] `탭바`를 통해 음식점 목록을 모든 음식점/자주 가는 음식점 으로 분류하여 볼 수 있다. +- [x] 음식점 목록에서 `별표 버튼`을 `클릭`해 자주 가는 음식점으로 `등록/해제` 할 수 있다. +- [x] 음식점 목록을 `클릭`해 음식점 상세 정보창을 열 수 있다. +- [x] 음식점 상세 정보창에서 `별표 버튼`을 `클릭`해 자주 가는 음식점으로 `등록/해제` 할 수 있다. +- [x] 음식점 상세 정보창에서 `닫기 버튼`을 `클릭`해 음식점 상세 정보 창을 닫을 수 있다. +- [x] 음식점 상세 정보창에서 `삭제 버튼`을 `클릭`해 음식점을 목록에서 삭제할 수 있다. diff --git a/src/App.ts b/src/App.ts index f32148980..745a25da4 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,16 +1,17 @@ import type { Component } from './interface'; import AddRestaurantDrawer from './pages/AddRestaurantDrawer'; import RestaurantListPage from './pages/RestaurantListPage'; +import GNB from './components/GNB'; type AppState = { - addRestaurantDrawerHide: boolean; + isDrawerHide: boolean; }; type AppProps = { $parent: HTMLElement; }; -class App implements Component { +export default class App implements Component { $target: HTMLElement; state: AppState; @@ -19,42 +20,38 @@ class App implements Component { this.$target.classList.add('app'); this.state = { - addRestaurantDrawerHide: true, + isDrawerHide: true, }; $parent.append(this.$target); } - setState(newState: AppState) { + public setState(newState: AppState) { this.state = newState; this.render(); } - render() { - const { addRestaurantDrawerHide } = this.state; + public render() { this.$target.innerHTML = ``; - - new RestaurantListPage({ + new GNB({ $parent: this.$target, - toggleAddRestaurantDrawer: this.toggleAddRestaurantDrawer.bind(this), + onToggleAddRestaurantDrawer: this.onToggleAddRestaurantDrawer.bind(this), }).render(); - if (!addRestaurantDrawerHide) { + new RestaurantListPage({ $parent: this.$target }).render(); + + if (!this.state.isDrawerHide) { new AddRestaurantDrawer({ $parent: this.$target, - toggleAddRestaurantDrawer: this.toggleAddRestaurantDrawer.bind(this), + onToggleAddRestaurantDrawer: this.onToggleAddRestaurantDrawer.bind(this), }).render(); } } - toggleAddRestaurantDrawer() { - const { addRestaurantDrawerHide } = this.state; - + public onToggleAddRestaurantDrawer() { this.setState({ ...this.state, - addRestaurantDrawerHide: !addRestaurantDrawerHide, + isDrawerHide: !this.state.isDrawerHide, }); } } - -export default App; diff --git a/src/components/DropDown.ts b/src/components/DropDown.ts index cb34af93e..6607ebbd2 100644 --- a/src/components/DropDown.ts +++ b/src/components/DropDown.ts @@ -15,9 +15,10 @@ type DropDownProps = { options: SelectOption[]; selectedOption: Category | SortBy; onChangeHandler: (e: Event) => void; + key: string; }; -class DropDown implements Component { +export default class DropDown implements Component { $target: HTMLSelectElement; state: DropDownState; @@ -29,30 +30,31 @@ class DropDown implements Component { options, selectedOption, onChangeHandler, + key, }: DropDownProps) { this.$target = document.createElement('select'); this.$target.classList.add(classNames ?? ''); this.$target.id = id ?? ''; this.$target.name = name ?? ''; + this.$target.dataset.key = key; this.state = { options, selectedOption, onChangeHandler, }; $parent.append(this.$target); - this.$target?.addEventListener('change', this.state.onChangeHandler); + this.addEvent(); } - setState(newState: DropDownState) { - this.state = newState; - this.render(); + private setSelectAttribute(selectedOption: string, target: string) { + return selectedOption === target ? 'selected' : ''; } - setSelectAttribute(selectedOption: string, target: string) { - return selectedOption === target ? 'selected' : ''; + private addEvent() { + this.$target?.addEventListener('change', this.state.onChangeHandler); } - render() { + public render() { this.$target.innerHTML = ` ${this.state.options .map( @@ -66,5 +68,3 @@ class DropDown implements Component { `; } } - -export default DropDown; diff --git a/src/components/FormItem.ts b/src/components/FormItem.ts new file mode 100644 index 000000000..bdb02dad5 --- /dev/null +++ b/src/components/FormItem.ts @@ -0,0 +1,14 @@ +export default class FormItem { + constructor(private template: string, private isRequired: boolean) { + this.template = template; + this.isRequired = isRequired; + } + + public getTemplate() { + return ` +
+ ${this.template} +
+ `; + } +} diff --git a/src/components/GNB.ts b/src/components/GNB.ts index c6aa37b51..6b09a3b5d 100644 --- a/src/components/GNB.ts +++ b/src/components/GNB.ts @@ -1,43 +1,40 @@ import type { Component } from '../interface'; type GNBState = { - toggleAddRestaurantDrawer: () => void; + onToggleAddRestaurantDrawer: () => void; }; type MainTemplateProps = { $parent: HTMLElement; - toggleAddRestaurantDrawer: () => void; + onToggleAddRestaurantDrawer: () => void; }; -class GNB implements Component { +export default class GNB implements Component { $target: HTMLElement; state: GNBState; - constructor({ $parent, toggleAddRestaurantDrawer }: MainTemplateProps) { + constructor({ $parent, onToggleAddRestaurantDrawer }: MainTemplateProps) { this.$target = document.createElement('header'); this.$target.classList.add('gnb'); - this.state = { toggleAddRestaurantDrawer }; + this.state = { onToggleAddRestaurantDrawer }; $parent.append(this.$target); } - setState(newState: GNBState) { - this.state = newState; - this.render(); + private addEvent() { + this.$target + .querySelector('button') + ?.addEventListener('click', this.state.onToggleAddRestaurantDrawer); } - render() { + public render() { this.$target.innerHTML = `

점심 뭐 먹지

`; - - const button = this.$target.querySelector('button'); - button?.addEventListener('click', this.state.toggleAddRestaurantDrawer); + this.addEvent(); } } - -export default GNB; diff --git a/src/components/RestaurantListItem.ts b/src/components/RestaurantListItem.ts index a864edf19..961f6fa4a 100644 --- a/src/components/RestaurantListItem.ts +++ b/src/components/RestaurantListItem.ts @@ -1,36 +1,64 @@ import type { Component } from '../interface'; import type { Restaurant } from '../type'; +import { fetchFavoriteId } from '../utils/api'; import { CATEGORY_IMAGE_URL } from '../utils/constants'; +import { getImgSrcByFavorite } from '../utils/util'; type RestaurantListItemState = { restaurant: Restaurant; + fetchNewRestaurants: () => void; + onOpenInfoDrawer: (e: Event) => void; }; type RestaurantListItemProps = { $parent: DocumentFragment; restaurant: Restaurant; + fetchNewRestaurants: () => void; + onOpenInfoDrawer: (e: Event) => void; }; -class RestaurantListItem implements Component { +export default class RestaurantListItem implements Component { $target: HTMLElement; state: RestaurantListItemState; - constructor({ $parent, restaurant }: RestaurantListItemProps) { + constructor({ + $parent, + restaurant, + fetchNewRestaurants, + onOpenInfoDrawer, + }: RestaurantListItemProps) { this.$target = document.createElement('li'); this.$target.classList.add('restaurant'); + this.$target.dataset.restaurantId = `${restaurant.id}`; - this.state = { restaurant }; + this.state = { restaurant, fetchNewRestaurants, onOpenInfoDrawer }; $parent.append(this.$target); } - setState(newState: RestaurantListItemState) { + public setState(newState: RestaurantListItemState) { this.state = newState; this.render(); } - render() { - const { category, name, distance, description } = this.state.restaurant; + private addEvent() { + this.$target.addEventListener('click', this.state.onOpenInfoDrawer); + this.$target.querySelector('.favorite__button')?.addEventListener('click', (e: Event) => { + e.stopPropagation(); + fetchFavoriteId(this.state.restaurant.id); + this.state.fetchNewRestaurants(); + this.setState({ + ...this.state, + restaurant: { + ...this.state.restaurant, + isFavorite: !this.state.restaurant.isFavorite, + }, + }); + }); + } + + public render() { + const { id, category, name, distance, description, isFavorite } = this.state.restaurant; this.$target.innerHTML = `
@@ -41,8 +69,13 @@ class RestaurantListItem implements Component { 캠퍼스부터 ${distance}분 내

${description}

+
+ +
`; + + this.addEvent(); } } - -export default RestaurantListItem; diff --git a/src/components/TabBar.ts b/src/components/TabBar.ts new file mode 100644 index 000000000..30310e1c1 --- /dev/null +++ b/src/components/TabBar.ts @@ -0,0 +1,46 @@ +import { Component } from '../interface'; +import { TabBarSelect } from '../type'; + +type TabBarState = { + tabBarSelect: TabBarSelect; + onClickTabBar: (e: Event) => void; +}; + +type TabBarProps = { + $parent: HTMLElement; + tabBarSelect: TabBarSelect; + onClickTabBar: (e: Event) => void; +}; + +export default class TabBar implements Component { + $target: HTMLElement; + state: TabBarState; + + constructor({ $parent, tabBarSelect, onClickTabBar }: TabBarProps) { + this.$target = document.createElement('div'); + this.$target.classList.add('tab-bar'); + this.state = { tabBarSelect, onClickTabBar }; + + $parent.appendChild(this.$target); + } + + private addEvent() { + this.$target.addEventListener('click', this.state.onClickTabBar); + } + + public render() { + this.$target.innerHTML = ` + + + `; + this.addEvent(); + } +} diff --git a/src/css/index.css b/src/css/index.css index 6629c2c79..61610a7b6 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -123,10 +123,10 @@ body { .restaurant { display: flex; align-items: flex-start; - padding: 16px 8px; - border-bottom: 1px solid #e9eaed; + + cursor: pointer; } .restaurant__category { @@ -197,7 +197,8 @@ body { position: fixed; bottom: 0; width: 100%; - + max-height: 500px; + overflow-y: scroll; padding: 32px 16px; border-radius: 8px 8px 0px 0px; @@ -224,7 +225,7 @@ body { padding-left: 4px; color: var(--primary-color); - content: "*"; + content: '*'; } .form-item .help-text { @@ -258,8 +259,8 @@ body { color: var(--grey-300); } -input[name="name"], -input[name="link"] { +input[name='name'], +input[name='link'] { height: 44px; } @@ -296,3 +297,57 @@ input[name="link"] { color: var(--grey-100); } + +.tab-bar { + margin: 20px 0; + display: flex; + width: 100%; + justify-content: center; +} + +.tab-bar-select { + width: 50%; + padding: 10px 0; + border: none; + font-weight: bold; + font-size: 16px; + background-color: white; + border-bottom: 2px solid var(--grey-200); + color: var(--grey-300); + cursor: pointer; +} + +.tab-bar-select[data-select='true'] { + border-bottom: 2px solid var(--primary-color); + color: var(--primary-color); +} + +.favorite__button { + border: none; + background-color: white; + cursor: pointer; +} + +.favorite__button:hover { + transform: scale(1.1); +} + +.modal-header { + display: flex; + justify-content: space-between; + padding: 0 30px; +} + +.restaurant-info__drawer { + padding: 0 30px; +} + +.restaurant-option { + position: absolute; + padding-right: 20px; + left: 95vw; +} + +.favorite__button-img { + pointer-events: none; +} diff --git a/src/index.js b/src/index.js index c5b9b9dde..037697434 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,8 @@ import '../image/category-etc.png'; import '../image/category-western.png'; import '../image/category-korean.png'; import '../image/category-japanese.png'; +import '../image/favorite-icon-filled.png'; +import '../image/favorite-icon-lined.png'; const fragment = document.createDocumentFragment(); new App({ $parent: fragment }).render(); diff --git a/src/interface.ts b/src/interface.ts index ef1c80198..6fb7ad8f5 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,6 +1,6 @@ export interface Component { $target: HTMLElement; state: T; - setState: (state: T) => void; - render: () => void; + setState?: (state: T) => void; + render?: () => void; } diff --git a/src/pages/AddRestaurantDrawer/index.ts b/src/pages/AddRestaurantDrawer/index.ts index b5421c492..41b2af338 100644 --- a/src/pages/AddRestaurantDrawer/index.ts +++ b/src/pages/AddRestaurantDrawer/index.ts @@ -1,127 +1,104 @@ import type { Component } from '../../interface'; import type { Restaurant } from '../../type'; +import { getNewId, postRestaurant } from '../../utils/api'; import { CATEGORIES, DEFAULT_CATEGORY, DEFAULT_DISTANCE, OPTION_START_INDEX, - REQUEST_RASTAURANT_KEY, } from '../../utils/constants'; +import category from './template/category'; +import description from './template/description'; +import distance from './template/distance'; +import link from './template/link'; +import name from './template/name'; +import FormItem from '../../components/FormItem'; type AddRestaurantDrawerState = { restaurantForm: Restaurant; - toggleAddRestaurantDrawer: () => void; + onToggleAddRestaurantDrawer: () => void; }; type AddRestaurantDrawerProps = { $parent: HTMLElement; - toggleAddRestaurantDrawer: () => void; + onToggleAddRestaurantDrawer: () => void; }; -class AddRestaurantDrawer implements Component { +export default class AddRestaurantDrawer implements Component { $target: HTMLElement; state: AddRestaurantDrawerState; - constructor({ $parent, toggleAddRestaurantDrawer }: AddRestaurantDrawerProps) { + constructor({ $parent, onToggleAddRestaurantDrawer }: AddRestaurantDrawerProps) { this.$target = document.createElement('div'); this.$target.classList.add('modal'); this.$target.classList.add('modal--open'); this.state = { restaurantForm: { + id: 0, name: '', category: DEFAULT_CATEGORY, distance: DEFAULT_DISTANCE, + isFavorite: false, }, - toggleAddRestaurantDrawer, + onToggleAddRestaurantDrawer, }; $parent.append(this.$target); this.render(); } - setState(newState: AddRestaurantDrawerState) { - this.state = newState; - this.render(); + private addEvent() { + const $cancelButton = this.$target.querySelector('#modal-cancel'); + const $modalForm = this.$target.querySelector('#modal-form') as HTMLElement; + + $cancelButton?.addEventListener('click', this.state.onToggleAddRestaurantDrawer); + $modalForm?.addEventListener('submit', this.onSubmitForm.bind(this)); + } + + public getTemplate() { + return ` + + + `; } - render() { - this.$target.innerHTML = ` - - - `; - - const $cancelButton = document.getElementById('modal-cancel'); - $cancelButton?.addEventListener('click', this.state.toggleAddRestaurantDrawer); - - const $modalForm = document.getElementById('modal-form'); - $modalForm?.addEventListener('submit', this.submitForm.bind(this)); + public render() { + this.$target.innerHTML = this.getTemplate(); + + this.addEvent(); } - submitForm(e: SubmitEvent) { + private onSubmitForm(e: SubmitEvent) { e.preventDefault(); - const restaurants = JSON.parse(localStorage.getItem(REQUEST_RASTAURANT_KEY) ?? '[]'); - restaurants.push(this.getFormValues(e.currentTarget as HTMLFormElement)); - localStorage.setItem(REQUEST_RASTAURANT_KEY, JSON.stringify(restaurants)); + postRestaurant(this.getFormValues(e.currentTarget as HTMLFormElement)); - this.state.toggleAddRestaurantDrawer(); + this.state.onToggleAddRestaurantDrawer(); } - getFormValues(form: HTMLFormElement) { + private getFormValues(form: HTMLFormElement) { + const id = getNewId(); const $category = form.category; const $name = form.querySelector('#name') as HTMLInputElement; const $distance = form.distance; @@ -129,13 +106,13 @@ class AddRestaurantDrawer implements Component { const $link = form.link; return { + id, category: $category?.options[$category.selectedIndex].value, name: $name.value, distance: $distance.options[$distance.selectedIndex].value, description: $description.value ?? '', link: $link.value ?? '', + isFavorite: false, }; } } - -export default AddRestaurantDrawer; diff --git a/src/pages/AddRestaurantDrawer/template/category.ts b/src/pages/AddRestaurantDrawer/template/category.ts new file mode 100644 index 000000000..2d7ec5268 --- /dev/null +++ b/src/pages/AddRestaurantDrawer/template/category.ts @@ -0,0 +1,14 @@ +import { CATEGORIES, OPTION_START_INDEX } from '../../../utils/constants'; + +const category = () => { + return ` + + `; +}; + +export default category; diff --git a/src/pages/AddRestaurantDrawer/template/description.ts b/src/pages/AddRestaurantDrawer/template/description.ts new file mode 100644 index 000000000..d8e912668 --- /dev/null +++ b/src/pages/AddRestaurantDrawer/template/description.ts @@ -0,0 +1,9 @@ +const description = () => { + return ` + + + 메뉴 등 추가 정보를 입력해 주세요. + `; +}; + +export default description; diff --git a/src/pages/AddRestaurantDrawer/template/distance.ts b/src/pages/AddRestaurantDrawer/template/distance.ts new file mode 100644 index 000000000..e043a4bac --- /dev/null +++ b/src/pages/AddRestaurantDrawer/template/distance.ts @@ -0,0 +1,15 @@ +const distance = () => { + return ` + + + `; +}; + +export default distance; diff --git a/src/pages/AddRestaurantDrawer/template/link.ts b/src/pages/AddRestaurantDrawer/template/link.ts new file mode 100644 index 000000000..2415695ad --- /dev/null +++ b/src/pages/AddRestaurantDrawer/template/link.ts @@ -0,0 +1,9 @@ +const link = () => { + return ` + + + 매장 정보를 확인할 수 있는 링크를 입력해 주세요. + `; +}; + +export default link; diff --git a/src/pages/AddRestaurantDrawer/template/name.ts b/src/pages/AddRestaurantDrawer/template/name.ts new file mode 100644 index 000000000..f94120f4a --- /dev/null +++ b/src/pages/AddRestaurantDrawer/template/name.ts @@ -0,0 +1,8 @@ +const name = () => { + return ` + + + `; +}; + +export default name; diff --git a/src/pages/RestaurantInfoDrawer/index.ts b/src/pages/RestaurantInfoDrawer/index.ts new file mode 100644 index 000000000..733592434 --- /dev/null +++ b/src/pages/RestaurantInfoDrawer/index.ts @@ -0,0 +1,96 @@ +import type { Component } from '../../interface'; +import { deleteById, fetchFavoriteId, getRestaurantById } from '../../utils/api'; +import { CATEGORY_IMAGE_URL } from '../../utils/constants'; +import { getImgSrcByFavorite } from '../../utils/util'; + +type RestaurantInfoDrawerState = { + selectId: number; + onToggleOpenDrawer: () => void; + fetchNewRestaurants: () => void; + onDeleteRestaurant: () => void; +}; + +type RestaurantInfoDrawerProps = { + $parent: HTMLElement; + selectId: number; + onToggleOpenDrawer: () => void; + fetchNewRestaurants: () => void; + onDeleteRestaurant: () => void; +}; + +export default class RestaurantInfoDrawer implements Component { + $target: HTMLElement; + state: RestaurantInfoDrawerState; + constructor({ + $parent, + selectId, + onToggleOpenDrawer, + fetchNewRestaurants, + onDeleteRestaurant, + }: RestaurantInfoDrawerProps) { + this.$target = document.createElement('div'); + this.$target.classList.add('modal'); + this.$target.classList.add('modal--open'); + + this.state = { onToggleOpenDrawer, selectId, fetchNewRestaurants, onDeleteRestaurant }; + + $parent.append(this.$target); + } + + private addEvent() { + this.$target.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + + switch (target.id) { + case 'restaurant-delete__button': + deleteById(this.state.selectId); + this.state.onDeleteRestaurant(); + break; + case 'drawer-close__button': + e.stopPropagation(); + this.state.onToggleOpenDrawer(); + break; + case 'favorite__button': + e.stopPropagation(); + fetchFavoriteId(this.state.selectId); + this.state.fetchNewRestaurants(); + this.render(); + break; + default: + return; + } + }); + } + + public render() { + const { category, name, distance, description, link, id, isFavorite } = getRestaurantById( + this.state.selectId + ); + + this.$target.innerHTML = ` + +