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,