diff --git a/src/apis/index.ts b/src/apis/index.ts index 4f7071a..018ee03 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,6 +1,8 @@ +import { createAxiosHTTPClient } from '@/utils/httpClient'; + import AuthApi from './authApi'; +import ServiceApi from './serviceApi'; import UserApi from './userApi'; -import { createAxiosHTTPClient } from '@/utils/httpClient'; const getApiBaseURL = (): string => { return process.env.APP_API_URL ?? '/'; @@ -11,3 +13,4 @@ const client = createAxiosHTTPClient({ baseURL, withCredentials: true }); export const authApi = new AuthApi(client, '/api/auth'); export const userApi = new UserApi(client, '/api/users'); +export const serviceApi = new ServiceApi(client, '/api/projects'); diff --git a/src/apis/serviceApi/index.ts b/src/apis/serviceApi/index.ts new file mode 100644 index 0000000..fa10c28 --- /dev/null +++ b/src/apis/serviceApi/index.ts @@ -0,0 +1,17 @@ +import { ServiceListAndCountResponseBody } from '@/types'; + +import Api from '../api'; + +class ServiceApi extends Api { + async getListAndCount(): Promise { + const response = await this.get(); + + return response.data; + } + + async deleteItem(id: string): Promise { + await this.delete({ additionalUri: `/${id}` }); + } +} + +export default ServiceApi; diff --git a/src/components/common/Loader/index.test.tsx b/src/components/common/Loader/index.test.tsx new file mode 100644 index 0000000..4c8ff6d --- /dev/null +++ b/src/components/common/Loader/index.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import Loader from '.'; + +it('Loader rendering test', () => { + render(); +}); diff --git a/src/components/common/Loader/index.tsx b/src/components/common/Loader/index.tsx new file mode 100644 index 0000000..85760d5 --- /dev/null +++ b/src/components/common/Loader/index.tsx @@ -0,0 +1,13 @@ +import React, { FC } from 'react'; + +import { loaderWrapperStyle, spinnerStyle } from './style'; + +const Loader: FC = () => { + return ( +
+
+
+ ); +}; + +export default Loader; diff --git a/src/components/common/Loader/style.ts b/src/components/common/Loader/style.ts new file mode 100644 index 0000000..17ac935 --- /dev/null +++ b/src/components/common/Loader/style.ts @@ -0,0 +1,30 @@ +import { SerializedStyles, Theme, css } from '@emotion/react'; +import zIndexes from '@/styles/zIndexes'; + +export const loaderWrapperStyle = css` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.2); + z-index: ${zIndexes.loader}; +`; + +export const spinnerStyle = (theme: Theme): SerializedStyles => css` + @keyframes spinner { + 0% { + transform: rotate(360deg); + } + } + margin: 0 auto; + width: 30px; + height: 30px; + border-radius: 50%; + border: 6px solid ${theme.colorPrimaryLight}; + border-top-color: ${theme.colorPrimary}; + animation: spinner 600ms linear infinite; +`; diff --git a/src/components/common/Navbar/index.tsx b/src/components/common/Navbar/index.tsx index b7c9407..9739b92 100644 --- a/src/components/common/Navbar/index.tsx +++ b/src/components/common/Navbar/index.tsx @@ -2,17 +2,17 @@ import React, { FC } from 'react'; import { Link } from 'react-router-dom'; import { useUserState } from '@/atoms/userState'; -import useConfirmModal from '@/hooks/useConfirmModal'; -import Uri from '@/constants/uri'; +import Logo from '@/components/common/Logo'; import PrivateNavbar from '@/components/nav/PrivateNavbar'; import PublicNavbar from '@/components/nav/PublicNavbar'; -import Logo from '@/components/common/Logo'; +import Uri from '@/constants/uri'; +import useConfirmModal from '@/hooks/useConfirmModal'; import { navStyle } from './style'; const Navbar: FC = () => { const [user, setUser] = useUserState(); - const [open] = useConfirmModal(); + const { open } = useConfirmModal(); const logoutHandler = () => { const logout = () => setUser(null); diff --git a/src/components/service/ServiceItem/index.test.tsx b/src/components/service/ServiceItem/index.test.tsx index 557ab18..744daf0 100644 --- a/src/components/service/ServiceItem/index.test.tsx +++ b/src/components/service/ServiceItem/index.test.tsx @@ -1,24 +1,32 @@ import React from 'react'; +import { RecoilRoot } from 'recoil'; import { render, screen } from '@testing-library/react'; import ServiceItem from '.'; describe(' component test', () => { + const id = 1; const name = 'serviceName'; const domain = 'serviceDomain'; const clientId = 'serviceClientId'; - it('서비스 아이템 정보를 렌더링해야 합니다.', () => { - render(); + it('서비스 아이템 정보 및 삭제 아이콘을 렌더링해야 합니다.', () => { + render( + + {}} + /> + , + ); expect(screen.queryByRole('heading', { level: 3 })).toHaveTextContent(name); expect(screen.queryByText(domain)).toBeInTheDocument(); expect(screen.queryByText(`Client ID : ${clientId}`)).toBeInTheDocument(); - }); - - it('서비스 아이템 삭제 아이콘을 렌더링해야 합니다.', () => { - render(); expect(screen.getByRole('img', { name: 'remove' })).toBeInTheDocument(); }); diff --git a/src/components/service/ServiceItem/index.tsx b/src/components/service/ServiceItem/index.tsx index 8c5517f..41337a6 100644 --- a/src/components/service/ServiceItem/index.tsx +++ b/src/components/service/ServiceItem/index.tsx @@ -12,15 +12,30 @@ import { } from './style'; type ServiceItemProps = { + id: number; name: string; domain: string; clientId: string; + onDeleteServiceItem: (id: string) => void; }; -const ServiceItem: FC = ({ name, domain, clientId }) => { - const [open] = useConfirmModal(); +const ServiceItem: FC = ({ + id, + name, + domain, + clientId, + onDeleteServiceItem, +}) => { + const { open, setConfirmModal } = useConfirmModal(); const openModal = () => { + setConfirmModal({ + acceptHandler: () => { + onDeleteServiceItem(id.toString()); + window.location.reload(); + }, + }); + open({ message: `Do you really want to delete ${name} service?` }); }; diff --git a/src/components/service/ServiceList/index.test.tsx b/src/components/service/ServiceList/index.test.tsx index f902cd2..8f47d78 100644 --- a/src/components/service/ServiceList/index.test.tsx +++ b/src/components/service/ServiceList/index.test.tsx @@ -10,10 +10,18 @@ describe(' component test', () => { const domain = 'serviceDomain'; const clientId = 'serviceClientId'; const serviceList = [{ id: 1, name, domain, clientId }]; - render(); + const serviceCount = 1; + + render( + {}} + />, + ); expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( - 'Services', + `Services ${serviceCount}`, ); expect(screen.queryByRole('heading', { level: 3 })).toHaveTextContent(name); expect(screen.queryByText(domain)).toBeInTheDocument(); @@ -21,7 +29,13 @@ describe(' component test', () => { }); it('serviceList가 빈 배열이면 서비스가 없다는 화면을 렌더링합니다.', () => { - render(); + render( + {}} + />, + ); expect(screen.getByRole('img', { name: 'empty' })).toBeInTheDocument(); expect(screen.queryByText('There are no services')).toBeInTheDocument(); diff --git a/src/components/service/ServiceList/index.tsx b/src/components/service/ServiceList/index.tsx index 5d07eaf..d0306b1 100644 --- a/src/components/service/ServiceList/index.tsx +++ b/src/components/service/ServiceList/index.tsx @@ -6,28 +6,38 @@ import ServiceItem from '../ServiceItem'; import { emptyStyle, headerStyle, listStyle, wrapperStyle } from './style'; type ServiceListProps = { + serviceCount: number; serviceList: { id: number; name: string; domain: string | null; clientId: string; }[]; + onDeleteServiceItem: (id: string) => void; }; -const ServiceList: FC = ({ serviceList }) => { +const ServiceList: FC = ({ + serviceCount, + serviceList, + onDeleteServiceItem, +}) => { return (
{serviceList && serviceList.length > 0 ? ( <> -

Services

+

+ Services {serviceCount} +

{serviceList.map((service) => { return ( ); })} diff --git a/src/components/service/ServiceList/style.ts b/src/components/service/ServiceList/style.ts index fc2ed70..563caeb 100644 --- a/src/components/service/ServiceList/style.ts +++ b/src/components/service/ServiceList/style.ts @@ -9,6 +9,10 @@ export const headerStyle = (theme: Theme): SerializedStyles => css` color: ${theme.colorPrimary}; font-size: 26px; margin: 30px 0; + + span { + font-size: 24px; + } `; export const listStyle = css` diff --git a/src/hooks/useConfirmModal.ts b/src/hooks/useConfirmModal.ts index a7dccb0..36d8598 100644 --- a/src/hooks/useConfirmModal.ts +++ b/src/hooks/useConfirmModal.ts @@ -1,13 +1,13 @@ import { - useSetConfirmModalState, ConfirmModalStateType, + useSetConfirmModalState, } from '@/atoms/confirmModalState'; -const useConfirmModal = (): readonly [ - open: (next?: Partial) => void, - close: (next?: Partial) => void, - setConfirmModal: (next?: Partial) => void, -] => { +const useConfirmModal = (): { + open: (next?: Partial) => void; + close: (next?: Partial) => void; + setConfirmModal: (next?: Partial) => void; +} => { const setConfirmModalState = useSetConfirmModalState(); const setConfirmModal = (next?: Partial) => @@ -19,7 +19,7 @@ const useConfirmModal = (): readonly [ const close = (next?: Partial) => setConfirmModalState((prev) => ({ ...prev, next, isOpen: false })); - return [open, close, setConfirmModal]; + return { open, close, setConfirmModal }; }; export default useConfirmModal; diff --git a/src/hooks/useService.ts b/src/hooks/useService.ts new file mode 100644 index 0000000..26d107c --- /dev/null +++ b/src/hooks/useService.ts @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react'; + +import { serviceApi } from '@/apis'; +import { NETWORK_ERROR, UNEXPECTED_ERROR } from '@/constants/error'; +import { ServiceBasicInfo } from '@/types'; + +const useService = (): { + isLoading: boolean; + error: string; + serviceInfo: { count: number; serviceList: ServiceBasicInfo[] }; + deleteService: (id: string) => Promise; +} => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [serviceInfo, setServiceInfo] = useState({ + count: 0, + serviceList: [], + }); + + const getServiceListAndCount = async () => { + try { + setIsLoading(true); + + const { count, projectList } = await serviceApi.getListAndCount(); + setServiceInfo({ + count, + serviceList: projectList, + }); + } catch (e) { + if (e.response) { + setError(UNEXPECTED_ERROR); + } else { + setError(NETWORK_ERROR); + } + throw e; + } finally { + setIsLoading(false); + } + }; + + const deleteService = async (id: string) => { + try { + setIsLoading(true); + + await serviceApi.deleteItem(id); + } catch (e) { + if (e.response) { + setError(UNEXPECTED_ERROR); + } else { + setError(NETWORK_ERROR); + } + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + getServiceListAndCount(); + }, []); + + return { + isLoading, + error, + serviceInfo, + deleteService, + }; +}; + +export default useService; diff --git a/src/pages/ServicePage/dummyData.ts b/src/pages/ServicePage/dummyData.ts deleted file mode 100644 index 6ed67eb..0000000 --- a/src/pages/ServicePage/dummyData.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const dummy = [ - { - id: 1, - name: 'telbby', - clientId: '12345678901234567890', - domain: 'telbby.com', - }, - { - id: 2, - name: 'Kakao', - clientId: '12345678901234567891', - domain: 'kakao.com', - }, - { - id: 3, - name: 'test', - clientId: '3wsqizhkpebg7kg6p08y', - domain: null, - }, - { - id: 7, - name: 'Naver', - clientId: 'teneqm97egqn853o4wpo', - domain: 'naver.com', - }, - { - id: 9, - name: 'test', - clientId: '5gmtkr9sekvjjovltgpl', - domain: 'test.com', - }, -]; diff --git a/src/pages/ServicePage/index.test.tsx b/src/pages/ServicePage/index.test.tsx index 6b08925..e87cd25 100644 --- a/src/pages/ServicePage/index.test.tsx +++ b/src/pages/ServicePage/index.test.tsx @@ -1,24 +1,23 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { RecoilRoot } from 'recoil'; import { render, screen } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; import ServicePage from '.'; -it('should render ServicePage contents', () => { - render( - - - - - , - ); +describe('ServicePage 테스트', () => { + it('ServicePage 렌더링 테스트', () => { + render( + + + + + , + ); - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( - 'Add to telbby', - ); - expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( - 'Services', - ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'Add to telbby', + ); + }); }); diff --git a/src/pages/ServicePage/index.tsx b/src/pages/ServicePage/index.tsx index 4b7abee..e8edbc1 100644 --- a/src/pages/ServicePage/index.tsx +++ b/src/pages/ServicePage/index.tsx @@ -1,14 +1,30 @@ -import React, { FC } from 'react'; +import React, { FC, useEffect } from 'react'; import Jumbotron from '@/components/common/Jumbotron'; import Layout from '@/components/common/Layout'; +import Loader from '@/components/common/Loader'; import ServiceList from '@/components/service/ServiceList'; import Shell from '@/components/shell/Shell'; +import useService from '@/hooks/useService'; +import useSnackbar from '@/hooks/useSnackbar'; +import theme from '@/styles/theme'; -import { dummy } from './dummyData'; import { servicePageStyle } from './style'; const ServicePage: FC = () => { + const { isLoading, error, serviceInfo, deleteService } = useService(); + const snackbar = useSnackbar({ + backgroundColor: theme.colorPrimary, + }); + + useEffect(() => { + if (error) { + snackbar.showMessage(error, { + duration: 1500, + }); + } + }, [error]); + /** * FIXME: * 가상 API 요청을 위해 만든 코드입니다. @@ -25,6 +41,7 @@ const ServicePage: FC = () => { return ( + {isLoading && }
{ width="90%" height="200px" /> - +
); diff --git a/src/styles/zIndexes.ts b/src/styles/zIndexes.ts index 0b95f57..229dc96 100644 --- a/src/styles/zIndexes.ts +++ b/src/styles/zIndexes.ts @@ -1,5 +1,6 @@ const zIndexes = { snackbar: 0, + loader: 1, }; export default zIndexes; diff --git a/src/types/index.ts b/src/types/index.ts index a0032eb..82ac873 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,3 @@ export * from './auth'; export * from './user'; +export * from './service'; diff --git a/src/types/service.ts b/src/types/service.ts index fbd5f16..af84fff 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -8,3 +8,15 @@ export interface ServiceInfo { firstQuestion: string; theme: number; } + +export type ServiceBasicInfo = { + id: number; + name: string; + clientId: string; + domain: string | null; +}; + +export type ServiceListAndCountResponseBody = { + projectList: ServiceBasicInfo[]; + count: number; +};