From 38eb5b2aa2701af92e1b20541543f3cd8902806e Mon Sep 17 00:00:00 2001 From: ParkGeunCheol <72205402+GC-Park@users.noreply.github.com> Date: Mon, 14 Aug 2023 13:48:02 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20feature/#267=20Suspense,=20ErrorBoundary?= =?UTF-8?q?,=20ErrorHandling=20=EC=9E=91=EC=97=85=20=EA=B5=AC=ED=98=84=20(?= =?UTF-8?q?#278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: tsconfig 동적 임포트 수행을 위해 module esnext로 변경 * feat: topic 관련 suspense 구현 * refactor: selectedTopic 토픽과 핀 화면 컴포넌트로 따로 분리 * feat: 핀 suspense 구현 및 적용 * feat: 에러 바운더리 코드 작성 * feat: react router로 에러 처리 및 에러 페이지 구현 * refactor: 지도 맵 get api 분리 * fix: 지도에서 좌표 없는 곳 클릭 시 에러 해결 및 toast 메시지 출력 * refactor: error 처리 버튼 내용 변경 * refactor: skeleton 필요없는 타입 삭제 --- frontend/src/apis/getMapApi.ts | 13 ++++ frontend/src/assets/LoginErrorIcon.svg | 9 +++ frontend/src/assets/NotFoundIcon.svg | 9 +++ .../ErrorBoundary/ApiErrorBoundary.tsx | 38 +++++++++++ .../src/components/ErrorBoundary/index.tsx | 38 +++++++++++ frontend/src/components/Loader/index.tsx | 33 +++++++++ .../src/components/NotFound/LoginError.tsx | 45 +++++++++++++ frontend/src/components/NotFound/index.tsx | 57 ++++++++++++++++ frontend/src/components/PinPreview/index.tsx | 4 +- .../PinsOfTopic/PinsOfTopicSkeleton.tsx | 21 ++++++ frontend/src/components/PinsOfTopic/index.tsx | 67 +++++++++++++++++++ .../SeeAllCardList/SeeAllCardListSkeleton.tsx | 19 ++++++ .../TopicCardList/TopicCardListSeleton.tsx | 15 +++++ .../components/TopicListContainer/index.tsx | 9 ++- .../components/common/PinSkeleton/index.tsx | 46 +++++++++++++ .../components/common/TopicSkeleton/index.tsx | 54 +++++++++++++++ frontend/src/hooks/useMapClick.ts | 7 ++ frontend/src/lib/getAddressFromServer.ts | 10 ++- frontend/src/pages/SeeAllTopics.tsx | 11 ++- frontend/src/pages/SelectedTopic.tsx | 61 ++++++----------- frontend/src/router.tsx | 2 + frontend/tsconfig.json | 2 +- 22 files changed, 518 insertions(+), 52 deletions(-) create mode 100644 frontend/src/apis/getMapApi.ts create mode 100644 frontend/src/assets/LoginErrorIcon.svg create mode 100644 frontend/src/assets/NotFoundIcon.svg create mode 100644 frontend/src/components/ErrorBoundary/ApiErrorBoundary.tsx create mode 100644 frontend/src/components/ErrorBoundary/index.tsx create mode 100644 frontend/src/components/Loader/index.tsx create mode 100644 frontend/src/components/NotFound/LoginError.tsx create mode 100644 frontend/src/components/NotFound/index.tsx create mode 100644 frontend/src/components/PinsOfTopic/PinsOfTopicSkeleton.tsx create mode 100644 frontend/src/components/PinsOfTopic/index.tsx create mode 100644 frontend/src/components/SeeAllCardList/SeeAllCardListSkeleton.tsx create mode 100644 frontend/src/components/TopicCardList/TopicCardListSeleton.tsx create mode 100644 frontend/src/components/common/PinSkeleton/index.tsx create mode 100644 frontend/src/components/common/TopicSkeleton/index.tsx diff --git a/frontend/src/apis/getMapApi.ts b/frontend/src/apis/getMapApi.ts new file mode 100644 index 00000000..5e552b9d --- /dev/null +++ b/frontend/src/apis/getMapApi.ts @@ -0,0 +1,13 @@ +export const getMapApi = (url: string) => + fetch(url, { + method: 'GET', + headers: { + 'Content-type': 'application/json', + }, + }) + .then((data) => { + return data.json(); + }) + .catch((error) => { + throw new Error(`${error.message}`); + }); diff --git a/frontend/src/assets/LoginErrorIcon.svg b/frontend/src/assets/LoginErrorIcon.svg new file mode 100644 index 00000000..a94bdc32 --- /dev/null +++ b/frontend/src/assets/LoginErrorIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/NotFoundIcon.svg b/frontend/src/assets/NotFoundIcon.svg new file mode 100644 index 00000000..e06909a0 --- /dev/null +++ b/frontend/src/assets/NotFoundIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/components/ErrorBoundary/ApiErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/ApiErrorBoundary.tsx new file mode 100644 index 00000000..c46b1433 --- /dev/null +++ b/frontend/src/components/ErrorBoundary/ApiErrorBoundary.tsx @@ -0,0 +1,38 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children?: ReactNode; + fallback: React.ElementType; +} + +interface State { + hasError: boolean; + info: Error | null; +} + +class ApiErrorBoundary extends Component { + public state: State = { + hasError: false, + info: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, info: error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo); + } + + public render() { + const { hasError, info } = this.state; + const { children } = this.props; + if (hasError) { + return ; + } + + return children; + } +} + +export default ApiErrorBoundary; diff --git a/frontend/src/components/ErrorBoundary/index.tsx b/frontend/src/components/ErrorBoundary/index.tsx new file mode 100644 index 00000000..94fc32bc --- /dev/null +++ b/frontend/src/components/ErrorBoundary/index.tsx @@ -0,0 +1,38 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children?: ReactNode; + fallback: React.ElementType; +} + +interface State { + hasError: boolean; + info: Error | null; +} + +class ErrorBoundary extends Component { + public state: State = { + hasError: false, + info: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, info: error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo); + } + + public render() { + const { hasError, info } = this.state; + const { children } = this.props; + if (hasError) { + return ; + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/Loader/index.tsx b/frontend/src/components/Loader/index.tsx new file mode 100644 index 00000000..90f7ed3c --- /dev/null +++ b/frontend/src/components/Loader/index.tsx @@ -0,0 +1,33 @@ +import styled, { keyframes } from 'styled-components'; + +const Loader = () => { + return ( + + + + ); +}; + +const Rotate = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +`; + +const LoaderBackWrapper = styled.div` + border-radius: 10px; +`; + +const LoaderWrapper = styled.div` + border: 10px solid #f3f3f3; + border-top: 10px solid #3498db; + border-radius: 50%; + width: 80px; + height: 80px; + animation: ${Rotate} 1s linear infinite; +`; + +export default Loader; diff --git a/frontend/src/components/NotFound/LoginError.tsx b/frontend/src/components/NotFound/LoginError.tsx new file mode 100644 index 00000000..e410b7d0 --- /dev/null +++ b/frontend/src/components/NotFound/LoginError.tsx @@ -0,0 +1,45 @@ +import { styled } from 'styled-components'; +import LoginErrorIcon from '../../assets/LoginErrorIcon.svg'; +import Button from '../common/Button'; +import Flex from '../common/Flex'; +import Space from '../common/Space'; +import Text from '../common/Text'; + +const LoginError = () => { + return ( + + + + + + 나만의 지도를 만들어 보세요. + + + 카카오로 시작하기 + + + ); +}; + +const NotFoundButton = styled(Button)` + width: 270px; + height: 56px; + + background-color: rgb(255, 220, 0); + + color: ${({ theme }) => theme.color.black}; + font-weight: ${({ theme }) => theme.fontWeight['bold']}; + border: 1px solid ${({ theme }) => theme.color.white}; +`; + +export default LoginError; diff --git a/frontend/src/components/NotFound/index.tsx b/frontend/src/components/NotFound/index.tsx new file mode 100644 index 00000000..706c5148 --- /dev/null +++ b/frontend/src/components/NotFound/index.tsx @@ -0,0 +1,57 @@ +import { styled } from 'styled-components'; +import NotFoundIcon from '../../assets/NotFoundIcon.svg'; +import useNavigator from '../../hooks/useNavigator'; +import Button from '../common/Button'; +import Flex from '../common/Flex'; +import Space from '../common/Space'; +import Text from '../common/Text'; + +const NotFound = () => { + const { routePage } = useNavigator(); + + return ( + + + + + + 요청하신 페이지를 찾을 수 없습니다. + + + 주소를 확인해 주세요. + + + routePage('/')}> + 메인페이지로 가기 + + + + ); +}; + +const NotFoundContainer = styled(Flex)` + flex-direction: row; + @media screen and (max-width: 700px) { + flex-direction: column; + } +`; + +const NotFoundButton = styled(Button)` + font-weight: ${({ theme }) => theme.fontWeight['bold']}; + + &:hover { + color: ${({ theme }) => theme.color.white}; + background-color: ${({ theme }) => theme.color.primary}; + border: 1px solid ${({ theme }) => theme.color.primary}; + } +`; +export default NotFound; diff --git a/frontend/src/components/PinPreview/index.tsx b/frontend/src/components/PinPreview/index.tsx index ad57240a..d8a729ce 100644 --- a/frontend/src/components/PinPreview/index.tsx +++ b/frontend/src/components/PinPreview/index.tsx @@ -9,11 +9,11 @@ export interface PinPreviewProps { pinTitle: string; pinLocation: string; pinInformation: string; - setSelectedPinId: (value: number) => void; + setSelectedPinId: React.Dispatch>; pinId: number; topicId: string | undefined; tagPins: string[]; - setTagPins: (value: string[]) => void; + setTagPins: React.Dispatch>; taggedPinIds: number[]; setTaggedPinIds: React.Dispatch>; setIsEditPinDetail: React.Dispatch>; diff --git a/frontend/src/components/PinsOfTopic/PinsOfTopicSkeleton.tsx b/frontend/src/components/PinsOfTopic/PinsOfTopicSkeleton.tsx new file mode 100644 index 00000000..e014aae1 --- /dev/null +++ b/frontend/src/components/PinsOfTopic/PinsOfTopicSkeleton.tsx @@ -0,0 +1,21 @@ +import Flex from '../common/Flex'; +import PinSkeleton from '../common/PinSkeleton'; +import Space from '../common/Space'; +import TopicSkeleton from '../common/TopicSkeleton'; + +const PinsOfTopicSkeleton = () => { + return ( + + + + + + + + + + + ); +}; + +export default PinsOfTopicSkeleton; diff --git a/frontend/src/components/PinsOfTopic/index.tsx b/frontend/src/components/PinsOfTopic/index.tsx new file mode 100644 index 00000000..15f6ec99 --- /dev/null +++ b/frontend/src/components/PinsOfTopic/index.tsx @@ -0,0 +1,67 @@ +import { TopicInfoType } from '../../types/Topic'; +import Space from '../common/Space'; +import PinPreview from '../PinPreview'; +import TopicInfo from '../TopicInfo'; + +interface PinsOfTopicProps { + topicId: string | undefined; + tagPins: string[]; + topicDetail: TopicInfoType[]; + taggedPinIds: number[]; + setSelectedPinId: React.Dispatch>; + setTagPins: React.Dispatch>; + setTaggedPinIds: React.Dispatch>; + setIsEditPinDetail: React.Dispatch>; +} + +const PinsOfTopic = ({ + topicId, + tagPins, + topicDetail, + taggedPinIds, + setSelectedPinId, + setTagPins, + setTaggedPinIds, + setIsEditPinDetail, +}: PinsOfTopicProps) => { + return ( + <> + {topicDetail.map((topic, idx) => { + return ( +
    + {idx !== 0 && } + + {topic.pins.map((pin) => ( +
  • + + +
  • + ))} +
+ ); + })} + + ); +}; + +export default PinsOfTopic; diff --git a/frontend/src/components/SeeAllCardList/SeeAllCardListSkeleton.tsx b/frontend/src/components/SeeAllCardList/SeeAllCardListSkeleton.tsx new file mode 100644 index 00000000..5ce89c6b --- /dev/null +++ b/frontend/src/components/SeeAllCardList/SeeAllCardListSkeleton.tsx @@ -0,0 +1,19 @@ +import Flex from '../common/Flex'; +import Space from '../common/Space'; +import TopicSkeleton from '../common/TopicSkeleton'; + +const SeeAllCardListSkeleton = () => { + return ( + + + + + + + + + + ); +}; + +export default SeeAllCardListSkeleton; diff --git a/frontend/src/components/TopicCardList/TopicCardListSeleton.tsx b/frontend/src/components/TopicCardList/TopicCardListSeleton.tsx new file mode 100644 index 00000000..d070f320 --- /dev/null +++ b/frontend/src/components/TopicCardList/TopicCardListSeleton.tsx @@ -0,0 +1,15 @@ +import Flex from '../common/Flex'; +import Space from '../common/Space'; +import TopicSkeleton from '../common/TopicSkeleton'; + +const TopicCardListSeleton = () => { + return ( + + + + + + ); +}; + +export default TopicCardListSeleton; diff --git a/frontend/src/components/TopicListContainer/index.tsx b/frontend/src/components/TopicListContainer/index.tsx index 2080153f..f73b39a7 100644 --- a/frontend/src/components/TopicListContainer/index.tsx +++ b/frontend/src/components/TopicListContainer/index.tsx @@ -1,8 +1,11 @@ import { styled } from 'styled-components'; import Flex from '../common/Flex'; import Text from '../common/Text'; -import TopicCardList from '../TopicCardList'; import Box from '../common/Box'; +import { lazy, Suspense } from 'react'; +import TopicCardListSeleton from '../TopicCardList/TopicCardListSeleton'; + +const TopicCardList = lazy(() => import('../TopicCardList')); interface TopicListContainerProps { containerTitle: string; @@ -40,7 +43,9 @@ const TopicListContainer = ({ 전체 보기 - + }> + + ); diff --git a/frontend/src/components/common/PinSkeleton/index.tsx b/frontend/src/components/common/PinSkeleton/index.tsx new file mode 100644 index 00000000..a9430d89 --- /dev/null +++ b/frontend/src/components/common/PinSkeleton/index.tsx @@ -0,0 +1,46 @@ +import { keyframes, styled } from 'styled-components'; +import Flex from '../Flex'; +import Space from '../Space'; + +const PinSkeleton = () => { + return ( + + + + + + + + ); +}; + +const skeletonAnimation = keyframes` + from { + opacity: 0.1; + } + to { + opacity: 1; + } +`; + +const SkeletonTitle = styled.div` + width: 320px; + height: 30px; + + border-radius: 8px; + + background: ${({ theme }) => theme.color['lightGray']}; + animation: ${skeletonAnimation} 1s infinite; +`; + +const SkeletonAddress = styled(SkeletonTitle)` + width: 320px; + height: 15px; +`; + +const SkeletonDescription = styled(SkeletonTitle)` + width: 320px; + height: 70px; +`; + +export default PinSkeleton; diff --git a/frontend/src/components/common/TopicSkeleton/index.tsx b/frontend/src/components/common/TopicSkeleton/index.tsx new file mode 100644 index 00000000..076c3eac --- /dev/null +++ b/frontend/src/components/common/TopicSkeleton/index.tsx @@ -0,0 +1,54 @@ +import { keyframes, styled } from 'styled-components'; +import Flex from '../Flex'; +import Space from '../Space'; + +const TopicSkeleton = () => { + return ( + <> + + + + + + + + + + + ); +}; + +const skeletonAnimation = keyframes` + from { + opacity: 0.1; + } + to { + opacity: 1; + } +`; + +const SkeletonImg = styled.div` + width: 172px; + height: 172px; + + border-radius: 8px; + + background: ${({ theme }) => theme.color['lightGray']}; + animation: ${skeletonAnimation} 1s infinite; +`; + +const SkeletonTitle = styled.div` + width: 172px; + height: 30px; + + border-radius: 8px; + + background: ${({ theme }) => theme.color['lightGray']}; + animation: ${skeletonAnimation} 1s infinite; +`; + +const SkeletonDescription = styled(SkeletonTitle)` + height: 50px; +`; + +export default TopicSkeleton; diff --git a/frontend/src/hooks/useMapClick.ts b/frontend/src/hooks/useMapClick.ts index 8058e1bf..4a7f9bc0 100644 --- a/frontend/src/hooks/useMapClick.ts +++ b/frontend/src/hooks/useMapClick.ts @@ -1,9 +1,11 @@ import { useContext, useEffect } from 'react'; import { CoordinatesContext } from '../context/CoordinatesContext'; import getAddressFromServer from '../lib/getAddressFromServer'; +import useToast from './useToast'; export default function useMapClick(map: any) { const { setClickedCoordinate } = useContext(CoordinatesContext); + const { showToast } = useToast(); useEffect(() => { if (!map) return; @@ -12,6 +14,11 @@ export default function useMapClick(map: any) { evt.data.lngLat._lat, evt.data.lngLat._lng, ); + + if (roadName.id) { + showToast('error', `제공되지 않는 주소 범위입니다.`); + } + setClickedCoordinate({ latitude: evt.data.lngLat._lat, longitude: evt.data.lngLat._lng, diff --git a/frontend/src/lib/getAddressFromServer.ts b/frontend/src/lib/getAddressFromServer.ts index 9ff2f874..42ad45e2 100644 --- a/frontend/src/lib/getAddressFromServer.ts +++ b/frontend/src/lib/getAddressFromServer.ts @@ -1,15 +1,19 @@ -import { getApi } from '../apis/getApi'; +import { getMapApi } from '../apis/getMapApi'; const getAddressFromServer = async (lat: any, lng: any) => { const version = '1'; const coordType = 'WGS84GEO'; const addressType = 'A10'; const callback = 'result'; - const addressData = await getApi( - 'tMap', + const addressData = await getMapApi( `https://apis.openapi.sk.com/tmap/geo/reversegeocoding?version=${version}&lat=${lat}&lon=${lng}&coordType=${coordType}&addressType=${addressType}&callback=${callback}&appKey=P2MX6F1aaf428AbAyahIl9L8GsIlES04aXS9hgxo `, ); + + if (addressData.error) { + return addressData.error; + } + const addressResult = addressData.addressInfo.fullAddress.split(','); return addressResult[2]; }; diff --git a/frontend/src/pages/SeeAllTopics.tsx b/frontend/src/pages/SeeAllTopics.tsx index ff700a79..e44a7fb5 100644 --- a/frontend/src/pages/SeeAllTopics.tsx +++ b/frontend/src/pages/SeeAllTopics.tsx @@ -1,7 +1,10 @@ import { useLocation } from 'react-router-dom'; -import SeeAllCardList from '../components/SeeAllCardList'; +// import SeeAllCardList from '../components/SeeAllCardList'; import Text from '../components/common/Text'; -import Space from '../components/common/Space'; +import { lazy, Suspense } from 'react'; +import SeeAllCardListSkeleton from '../components/SeeAllCardList/SeeAllCardListSkeleton'; + +const SeeAllCardList = lazy(() => import('../components/SeeAllCardList')); const SeeAllTopics = () => { const { state } = useLocation(); @@ -13,7 +16,9 @@ const SeeAllTopics = () => { {title} - + }> + + ); }; diff --git a/frontend/src/pages/SelectedTopic.tsx b/frontend/src/pages/SelectedTopic.tsx index 2eca3bab..14d7cbc6 100644 --- a/frontend/src/pages/SelectedTopic.tsx +++ b/frontend/src/pages/SelectedTopic.tsx @@ -1,9 +1,7 @@ -import { useContext, useEffect, useState } from 'react'; +import { lazy, Suspense, useContext, useEffect, useState } from 'react'; import { styled } from 'styled-components'; import Space from '../components/common/Space'; import Flex from '../components/common/Flex'; -import PinPreview from '../components/PinPreview'; -import TopicInfo from '../components/TopicInfo'; import { TopicInfoType } from '../types/Topic'; import { useParams, useSearchParams } from 'react-router-dom'; import theme from '../themes'; @@ -13,6 +11,9 @@ import { MergeOrSeeTogether } from '../components/MergeOrSeeTogether'; import { CoordinatesContext } from '../context/CoordinatesContext'; import useNavigator from '../hooks/useNavigator'; import { LayoutWidthContext } from '../context/LayoutWidthContext'; +import PinsOfTopicSkeleton from '../components/PinsOfTopic/PinsOfTopicSkeleton'; + +const PinsOfTopic = lazy(() => import('../components/PinsOfTopic')); const SelectedTopic = () => { const { topicId } = useParams(); @@ -103,44 +104,22 @@ const SelectedTopic = () => { onClickClose={onTagCancel} /> )} - {topicDetail.length !== 0 ? ( - topicDetail.map((topic, idx) => { - return ( -
    - {idx !== 0 && } - - {topic.pins.map((pin) => ( -
  • - - -
  • - ))} -
- ); - }) - ) : ( - <> - )} + }> + {topicDetail.length !== 0 ? ( + + ) : ( + <> + )} + {selectedPinId && ( <> diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index caf8b229..c4cc38b1 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -5,11 +5,13 @@ import NewTopic from './pages/NewTopic'; import RootPage from './pages/RootPage'; import SelectedTopic from './pages/SelectedTopic'; import SeeAllTopics from './pages/SeeAllTopics'; +import NotFound from './components/NotFound'; const routes = [ { path: '/', element: , + errorElement: , children: [ { path: '', diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 485fbf00..0f919450 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -6,7 +6,7 @@ "skipLibCheck": true, "moduleResolution": "Node", "strict": true, - "module": "es6", + "module": "esnext", "isolatedModules": true, "allowSyntheticDefaultImports": true, "noEmit": true,