From dffd50821dd229755e37bdfbab3a59add2f31349 Mon Sep 17 00:00:00 2001 From: JO YUN HO Date: Thu, 9 Sep 2021 16:12:31 +0900 Subject: [PATCH 01/25] =?UTF-8?q?refactor:=20GuestReservation=EA=B3=BC=20G?= =?UTF-8?q?uestReservationEdit=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20(#518)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 예약자 예약 관리 기능을 하나의 컴포넌트에서 하도록 통합 - GuestReservationEdit 컴포넌트를 GuestReservation 컴포넌트에 병합 * refactor: 코드 정리 및 불필요한 컴포넌트 제거 * refactor: useInput으로 관리하던 상태들을 useInputs로 관리하도록 변경 * refactor: ReservationForm 컴포넌트 분리 * refactor: 매직 넘버 상수화 * refactor: window scroll 초기화 기능 Custom Hook으로 분리 * refactor: Form과 관련 없는 요소들 분리 * refactor: 현재 모드를 판단할 수 있는 식별자를 추가 * refactor: 시간 관련 상수 별도의 파일로 분리 * refactor: GuestReservation 컴포넌트와 ReservationForm 컴포넌트의 관심사 분리 * refactor: 매직 넘버 상수로 분리 * refactor: date를 props로 전달하도록 변경 * refactor: 매직넘버 상수화 * refactor: Event Type 컨벤션 통일 * fix: 텍스트의 줄 바꿈이 제대로 되지 않는 오류 수정 * reafctor: 네이밍 및 코드 컨벤션 통일 * refactor: initialTime, initialEndTime 설정 로직을 함수로 분리 * refactor: 예약 생성, 수정의 판단을 GuestReservation 컴포넌트에서 하도록 변경 * refactor: 디렉토리 구조 정리 * refactor: 오타 수정 --- frontend/src/api/guestReservation.ts | 2 +- frontend/src/constants/message.ts | 6 + frontend/src/constants/routes.tsx | 3 +- frontend/src/constants/time.ts | 7 + frontend/src/hooks/useScrollToTop.ts | 9 + .../GuestReservation.styles.ts | 45 +--- .../GuestReservation/GuestReservation.tsx | 251 +++++++----------- .../units/ReservationForm.styles.ts} | 28 +- .../units/ReservationForm.tsx | 189 +++++++++++++ .../GuestReservationEdit.tsx | 217 --------------- 10 files changed, 315 insertions(+), 442 deletions(-) create mode 100644 frontend/src/constants/time.ts create mode 100644 frontend/src/hooks/useScrollToTop.ts rename frontend/src/pages/{GuestReservationEdit/GuestReservationEdit.styles.ts => GuestReservation/units/ReservationForm.styles.ts} (51%) create mode 100644 frontend/src/pages/GuestReservation/units/ReservationForm.tsx delete mode 100644 frontend/src/pages/GuestReservationEdit/GuestReservationEdit.tsx diff --git a/frontend/src/api/guestReservation.ts b/frontend/src/api/guestReservation.ts index 269fe991d..e414b5797 100644 --- a/frontend/src/api/guestReservation.ts +++ b/frontend/src/api/guestReservation.ts @@ -13,7 +13,7 @@ export interface QuerySpaceReservationsParams extends QueryMapReservationsParams spaceId: number; } -interface ReservationParams { +export interface ReservationParams { reservation: { startDateTime: Date; endDateTime: Date; diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index 239a15f26..d44ab3e2b 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -15,11 +15,17 @@ const MESSAGE = { UNEXPECTED_ERROR: '로그인에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', }, RESERVATION: { + CREATE: '예약하기', + EDIT: '예약 수정하기', + SUGGESTION: '오늘의 첫 예약을 잡아보세요!', + PENDING: '불러오는 중입니다...', + ERROR: `예약 목록을 불러오는 데 문제가 생겼어요!\n새로 고침으로 다시 시도해주세요.`, DELETE_SUCCESS: '예약이 삭제 되었습니다.', UNEXPECTED_ERROR: '예약하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', UNEXPECTED_DELETE_ERROR: '예약을 삭제하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', INVALID_MAP_ID: '맵 ID가 올바르지 않습니다. 다시 확인해주세요.', + PASSWORD_MESSAGE: '숫자 4자리를 입력해주세요.', }, MANAGER_MAIN: { UNEXPECTED_GET_DATA_ERROR: diff --git a/frontend/src/constants/routes.tsx b/frontend/src/constants/routes.tsx index 92c9bcba1..6ecdefa3c 100644 --- a/frontend/src/constants/routes.tsx +++ b/frontend/src/constants/routes.tsx @@ -1,7 +1,6 @@ import { ReactNode } from 'react'; import GuestMap from 'pages/GuestMap/GuestMap'; import GuestReservation from 'pages/GuestReservation/GuestReservation'; -import GuestReservationEdit from 'pages/GuestReservationEdit/GuestReservationEdit'; import Main from 'pages/Main/Main'; import ManagerJoin from 'pages/ManagerJoin/ManagerJoin'; import ManagerLogin from 'pages/ManagerLogin/ManagerLogin'; @@ -43,7 +42,7 @@ export const PUBLIC_ROUTES: Route[] = [ }, { path: PATH.GUEST_RESERVATION_EDIT, - component: , + component: , }, ]; diff --git a/frontend/src/constants/time.ts b/frontend/src/constants/time.ts new file mode 100644 index 000000000..f0d72fc07 --- /dev/null +++ b/frontend/src/constants/time.ts @@ -0,0 +1,7 @@ +const TIME = { + SECONDS_PER_MINUTE: 60, + MILLISECONDS_PER_SECOND: 1000, + MILLISECONDS_PER_MINUTE: 60000, +}; + +export default TIME; diff --git a/frontend/src/hooks/useScrollToTop.ts b/frontend/src/hooks/useScrollToTop.ts new file mode 100644 index 000000000..22f0f7f43 --- /dev/null +++ b/frontend/src/hooks/useScrollToTop.ts @@ -0,0 +1,9 @@ +import { useEffect } from 'react'; + +const useScrollToTop = (): void => { + useEffect(() => { + window.scrollTo(0, 0); + }, []); +}; + +export default useScrollToTop; diff --git a/frontend/src/pages/GuestReservation/GuestReservation.styles.ts b/frontend/src/pages/GuestReservation/GuestReservation.styles.ts index d8d28d55e..2e3996988 100644 --- a/frontend/src/pages/GuestReservation/GuestReservation.styles.ts +++ b/frontend/src/pages/GuestReservation/GuestReservation.styles.ts @@ -5,12 +5,16 @@ interface ColorDotProps { color: Color; } -export const ReservationForm = styled.form` - margin: 1.5rem 0 5rem 0; +export const Section = styled.section` + margin: 1.5rem 0 4.5rem; `; -export const Section = styled.section` - margin: 1.5rem 0; +export const Message = styled.p` + white-space: pre-wrap; +`; + +export const ReservationList = styled.div` + border-top: 1px solid ${({ theme }) => theme.gray[400]}; `; export const PageHeader = styled.h2` @@ -21,39 +25,6 @@ export const PageHeader = styled.h2` align-items: center; `; -export const InputWrapper = styled.div` - position: relative; - display: flex; - gap: 1rem; - margin: 1.625rem 0; - - label { - flex: 1; - } -`; - -export const ReservationList = styled.div` - border-top: 1px solid ${({ theme }) => theme.gray[400]}; -`; - -export const ButtonWrapper = styled.div` - position: fixed; - bottom: 0; - left: 0; - width: 100vw; -`; - -export const TimeFormMessage = styled.p` - position: absolute; - left: 0.75rem; - bottom: -1rem; - font-size: 0.75rem; - height: 1em; - color: ${({ theme }) => theme.gray[500]}; -`; - -export const Message = styled.p``; - export const ColorDot = styled.span` display: inline-block; width: 1rem; diff --git a/frontend/src/pages/GuestReservation/GuestReservation.tsx b/frontend/src/pages/GuestReservation/GuestReservation.tsx index 6335bf752..8677ffb11 100644 --- a/frontend/src/pages/GuestReservation/GuestReservation.tsx +++ b/frontend/src/pages/GuestReservation/GuestReservation.tsx @@ -1,35 +1,36 @@ import { AxiosError } from 'axios'; -import { FormEventHandler, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useMutation } from 'react-query'; import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { postGuestReservation } from 'api/guestReservation'; -import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; -import Button from 'components/Button/Button'; +import { postGuestReservation, putGuestReservation, ReservationParams } from 'api/guestReservation'; import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; import Layout from 'components/Layout/Layout'; import PageHeader from 'components/PageHeader/PageHeader'; import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; import MESSAGE from 'constants/message'; -import REGEXP from 'constants/regexp'; -import RESERVATION from 'constants/reservation'; +import { HREF } from 'constants/path'; import useGuestReservations from 'hooks/useGuestReservations'; import useInput from 'hooks/useInput'; import { GuestMapState } from 'pages/GuestMap/GuestMap'; -import { MapItem, ScrollPosition, Space } from 'types/common'; +import { MapItem, Reservation, ScrollPosition, Space } from 'types/common'; import { ErrorResponse } from 'types/response'; -import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; import * as Styled from './GuestReservation.styles'; +import ReservationForm from './units/ReservationForm'; + +interface URLParameter { + sharingMapId: MapItem['sharingMapId']; +} interface GuestReservationState { mapId: number; space: Space; selectedDate: string; scrollPosition: ScrollPosition; + reservation?: Reservation; } -interface URLParameter { - sharingMapId: MapItem['sharingMapId']; +export interface EditReservationParams extends ReservationParams { + reservationId?: number; } const GuestReservation = (): JSX.Element => { @@ -37,40 +38,22 @@ const GuestReservation = (): JSX.Element => { const history = useHistory(); const { sharingMapId } = useParams(); - const { mapId, space, selectedDate, scrollPosition } = location.state; - const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = - space.settings; + const { mapId, space, selectedDate, scrollPosition, reservation } = location.state; - if (!mapId || !space) history.replace(`/guest/${sharingMapId}`); + if (!mapId || !space) history.replace(HREF.GUEST_MAP(sharingMapId)); - const now = new Date(); - const todayDate = formatDate(new Date()); - - const initialStartTime = formatTime(now); - const initialEndTime = formatTime( - new Date(new Date().getTime() + 1000 * 60 * reservationTimeUnit) - ); - const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); - const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); - - const [name, onChangeName] = useInput(''); - const [description, onChangeDescription] = useInput(''); const [date, onChangeDate] = useInput(selectedDate); - const [startTime, onChangeStartTime] = useInput(initialStartTime); - const [endTime, onChangeEndTime] = useInput(initialEndTime); - const [password, onChangePassword] = useInput(''); - const startDateTime = new Date(`${date}T${startTime}Z`); - const endDateTime = new Date(`${date}T${endTime}Z`); + const isEditMode = !!reservation; const getReservations = useGuestReservations({ mapId, spaceId: space.id, date }); const reservations = getReservations.data?.data?.reservations ?? []; - const createReservation = useMutation(postGuestReservation, { + const addReservation = useMutation(postGuestReservation, { onSuccess: () => { - history.push(`/guest/${sharingMapId}`, { + history.push(HREF.GUEST_MAP(sharingMapId), { spaceId: space.id, - targetDate: new Date(`${date}T${startTime}`), + targetDate: new Date(date), }); }, onError: (error: AxiosError) => { @@ -78,149 +61,101 @@ const GuestReservation = (): JSX.Element => { }, }); - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); + const updateReservation = useMutation(putGuestReservation, { + onSuccess: () => { + history.push(HREF.GUEST_MAP(sharingMapId), { + spaceId: space.id, + targetDate: new Date(date), + }); + }, - if (createReservation.isLoading) return; + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); + }, + }); + + const createReservation = ({ reservation }: ReservationParams) => { + if (addReservation.isLoading) return; + + addReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + }); + }; - const reservation = { name, description, password, startDateTime, endDateTime }; + const editReservation = ({ reservation, reservationId }: EditReservationParams) => { + if (updateReservation.isLoading || !isEditMode || !reservationId) return; - createReservation.mutate({ reservation, mapId, spaceId: space.id }); + updateReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + reservationId, + }); + }; + + const handleSubmit = ( + event: React.FormEvent, + { reservation, reservationId }: EditReservationParams + ) => { + event.preventDefault(); + + reservationId + ? editReservation({ reservation, reservationId }) + : createReservation({ reservation }); }; useEffect(() => { return history.listen((location) => { if ( - location.pathname === `/guest/${sharingMapId}` || - location.pathname === `/guest/${sharingMapId}/` + location.pathname === HREF.GUEST_MAP(sharingMapId) || + location.pathname === HREF.GUEST_MAP(sharingMapId) + '/' ) { location.state = { spaceId: space.id, - targetDate: new Date(selectedDate), + targetDate: new Date(date), scrollPosition, }; } }); - }, [history, scrollPosition, selectedDate, space, sharingMapId]); - - useEffect(() => { - window.scrollTo(0, 0); - }, []); + }, [history, scrollPosition, date, space, sharingMapId]); return ( <>
- - - - - {space.name} - - - - - - - - - } - value={date} - min={formatDate(now)} - onChange={onChangeDate} - required - /> - - - - - - 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} - {formatTimePrettier(reservationMaximumTimeUnit)}) - - - - - - - - - {getReservations.isLoadingError && ( - - 예약 목록을 불러오는 데 문제가 생겼어요! -
- 새로 고침으로 다시 시도해주세요. -
- )} - {getReservations.isLoading && !getReservations.isLoadingError && ( - 불러오는 중입니다... - )} - {getReservations.isSuccess && reservations.length === 0 && ( - 오늘의 첫 예약을 잡아보세요! - )} - {getReservations.isSuccess && reservations.length > 0 && ( - - {reservations?.map((reservation) => ( - - ))} - - )} -
- - - - -
+ + + {space.name} + + + + + {getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.ERROR} + )} + {getReservations.isLoading && !getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.PENDING} + )} + {getReservations.isSuccess && reservations.length === 0 && ( + {MESSAGE.RESERVATION.SUGGESTION} + )} + {getReservations.isSuccess && reservations.length > 0 && ( + + {reservations?.map((reservation) => ( + + ))} + + )} +
); diff --git a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.styles.ts b/frontend/src/pages/GuestReservation/units/ReservationForm.styles.ts similarity index 51% rename from frontend/src/pages/GuestReservationEdit/GuestReservationEdit.styles.ts rename to frontend/src/pages/GuestReservation/units/ReservationForm.styles.ts index 1ba1cfc8d..5723fe860 100644 --- a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.styles.ts +++ b/frontend/src/pages/GuestReservation/units/ReservationForm.styles.ts @@ -1,18 +1,7 @@ import styled from 'styled-components'; -import { Color } from 'types/common'; - -interface ColorDotProps { - color: Color; -} export const ReservationForm = styled.form` - margin: 1.5rem 0 5rem 0; -`; - -export const PageHeader = styled.h2` - font-size: 1.625rem; - font-weight: 700; - margin: 1.5rem 0; + margin: 1.5rem 0 0; `; export const Section = styled.section` @@ -30,10 +19,6 @@ export const InputWrapper = styled.div` } `; -export const ReservationList = styled.div` - border-top: 1px solid ${({ theme }) => theme.gray[400]}; -`; - export const ButtonWrapper = styled.div` position: fixed; bottom: 0; @@ -49,14 +34,3 @@ export const TimeFormMessage = styled.p` height: 1em; color: ${({ theme }) => theme.gray[500]}; `; - -export const Message = styled.p``; - -export const ColorDot = styled.span` - display: inline-block; - width: 1rem; - height: 1rem; - background-color: ${({ color }) => color}; - border-radius: 50%; - margin-right: 0.75rem; -`; diff --git a/frontend/src/pages/GuestReservation/units/ReservationForm.tsx b/frontend/src/pages/GuestReservation/units/ReservationForm.tsx new file mode 100644 index 000000000..b763bbe2d --- /dev/null +++ b/frontend/src/pages/GuestReservation/units/ReservationForm.tsx @@ -0,0 +1,189 @@ +import { ChangeEventHandler } from 'react'; +import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import RESERVATION from 'constants/reservation'; +import TIME from 'constants/time'; +import useInputs from 'hooks/useInputs'; +import useScrollToTop from 'hooks/useScrollToTop'; +import { Reservation, Space } from 'types/common'; +import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; +import { EditReservationParams } from '../GuestReservation'; +import * as Styled from './ReservationForm.styles'; + +interface Props { + isEditMode: boolean; + space: Space; + reservation?: Reservation; + date: string; + onChangeDate: ChangeEventHandler; + onSubmit: ( + event: React.FormEvent, + { reservation, reservationId }: EditReservationParams + ) => void; +} + +interface Form { + name: string; + description: string; + startTime: string; + endTime: string; + password: string; +} + +const ReservationForm = ({ + isEditMode, + space, + date, + reservation, + onSubmit, + onChangeDate, +}: Props): JSX.Element => { + useScrollToTop(); + + const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = + space.settings; + + const now = new Date(); + const todayDate = formatDate(new Date()); + + const getInitialStartTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.startDateTime)); + } + + return formatTime(now); + }; + + const getInitialEndTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.endDateTime)); + } + + return formatTime( + new Date(new Date().getTime() + TIME.MILLISECONDS_PER_MINUTE * reservationTimeUnit) + ); + }; + + const initialStartTime = getInitialStartTime(); + const initialEndTime = getInitialEndTime(); + + const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); + const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); + + const [{ name, description, startTime, endTime, password }, onChangeForm] = useInputs
({ + name: reservation?.name ?? '', + description: reservation?.description ?? '', + startTime: initialStartTime, + endTime: initialEndTime, + password: '', + }); + + const startDateTime = new Date(`${date}T${startTime}Z`); + const endDateTime = new Date(`${date}T${endTime}Z`); + + return ( + + onSubmit(event, { + reservation: { + startDateTime, + endDateTime, + password, + name, + description, + }, + reservationId: reservation?.id, + }) + } + > + + + + + + + + + } + value={date} + min={formatDate(now)} + onChange={onChangeDate} + required + /> + + + + + + 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} + {formatTimePrettier(reservationMaximumTimeUnit)}) + + + + + + + + + + + ); +}; + +export default ReservationForm; diff --git a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.tsx b/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.tsx deleted file mode 100644 index 3aa7dbcfa..000000000 --- a/frontend/src/pages/GuestReservationEdit/GuestReservationEdit.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { AxiosError } from 'axios'; -import { FormEventHandler } from 'react'; -import { useMutation } from 'react-query'; -import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { putGuestReservation } from 'api/guestReservation'; -import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; -import Button from 'components/Button/Button'; -import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; -import Layout from 'components/Layout/Layout'; -import PageHeader from 'components/PageHeader/PageHeader'; -import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; -import MESSAGE from 'constants/message'; -import REGEXP from 'constants/regexp'; -import RESERVATION from 'constants/reservation'; -import useGuestReservations from 'hooks/useGuestReservations'; -import useInput from 'hooks/useInput'; -import { GuestMapState } from 'pages/GuestMap/GuestMap'; -import { MapItem, Reservation, Space } from 'types/common'; -import { ErrorResponse } from 'types/response'; -import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; -import * as Styled from './GuestReservationEdit.styles'; - -interface GuestReservationEditState { - mapId: number; - space: Space; - reservation: Reservation; - selectedDate: string; -} - -interface URLParameter { - sharingMapId: MapItem['sharingMapId']; -} - -const GuestReservationEdit = (): JSX.Element => { - const location = useLocation(); - const history = useHistory(); - const { sharingMapId } = useParams(); - - const { mapId, space, reservation, selectedDate } = location.state; - const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = - space.settings; - - if (!mapId || !space || !reservation) history.replace(`/guest/${sharingMapId}`); - - const now = new Date(); - const todayDate = formatDate(new Date()); - - const [name, onChangeName] = useInput(reservation.name); - const [description, onChangeDescription] = useInput(reservation.description); - const [date, onChangeDate] = useInput(selectedDate); - const [startTime, onChangeStartTime] = useInput(formatTime(new Date(reservation.startDateTime))); - const [endTime, onChangeEndTime] = useInput(formatTime(new Date(reservation.endDateTime))); - const [password, onChangePassword] = useInput(''); - - const startDateTime = new Date(`${date}T${startTime}Z`); - const endDateTime = new Date(`${date}T${endTime}Z`); - - const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); - const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); - - const getReservations = useGuestReservations({ mapId, spaceId: space.id, date }); - const reservations = getReservations.data?.data?.reservations ?? []; - - const editReservation = useMutation(putGuestReservation, { - onSuccess: () => { - history.push(`/guest/${sharingMapId}`, { - spaceId: space.id, - targetDate: new Date(`${date}T${startTime}`), - }); - }, - - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); - }, - }); - - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - - if (editReservation.isLoading) return; - - const editReservationParams = { - name, - description, - password, - startDateTime, - endDateTime, - }; - - editReservation.mutate({ - reservation: editReservationParams, - mapId, - spaceId: space.id, - reservationId: reservation.id, - }); - }; - - return ( - <> -
- - - - - - {space.name} - - - - - - - - - } - value={date} - min={formatDate(now)} - onChange={onChangeDate} - required - /> - - - - - - 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} - {formatTimePrettier(reservationMaximumTimeUnit)}) - - - - - - - - - {getReservations.isLoadingError && ( - - 예약 목록을 불러오는 데 문제가 생겼어요! -
- 새로 고침으로 다시 시도해주세요. -
- )} - {getReservations.isLoading && !getReservations.isLoadingError && ( - 불러오는 중입니다... - )} - {getReservations.isSuccess && reservations.length > 0 && ( - - {reservations?.map((reservation) => ( - - ))} - - )} -
- - - -
-
- - ); -}; - -export default GuestReservationEdit; From cd24190e5e36f0ddd3eb8924c00acfe0c89f1659 Mon Sep 17 00:00:00 2001 From: JO YUN HO Date: Fri, 10 Sep 2021 10:51:28 +0900 Subject: [PATCH 02/25] =?UTF-8?q?refactor:=20Login,=20Join=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20Form=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: LoginForm 컴포넌트 분리 * refactor: JoinForm 컴포넌트 분리 * refactor: 디렉토리 구조 정리 * refactor: ManagerLogin 컴포넌트와 LoginForm 컴포넌트의 관심사 분리 * refactor: ManagerJoin 컴포넌트와 JoinForm 컴포넌트의 관심사 분리 * refactor: LoginForm의 submit handler함수 분리 * refactor: 상태 메세지 로직 정리 * refactor: 불필요한 is prefix 제거 --- .../pages/ManagerJoin/ManagerJoin.styles.ts | 8 - .../src/pages/ManagerJoin/ManagerJoin.tsx | 154 ++-------------- .../ManagerJoin/units/JoinForm.styles.ts | 9 + .../src/pages/ManagerJoin/units/JoinForm.tsx | 164 ++++++++++++++++++ .../pages/ManagerLogin/ManagerLogin.styles.ts | 9 - .../src/pages/ManagerLogin/ManagerLogin.tsx | 64 ++----- .../ManagerLogin/units/LoginForm.styles.ts | 9 + .../pages/ManagerLogin/units/LoginForm.tsx | 64 +++++++ 8 files changed, 284 insertions(+), 197 deletions(-) create mode 100644 frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts create mode 100644 frontend/src/pages/ManagerJoin/units/JoinForm.tsx create mode 100644 frontend/src/pages/ManagerLogin/units/LoginForm.styles.ts create mode 100644 frontend/src/pages/ManagerLogin/units/LoginForm.tsx diff --git a/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts b/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts index ec42bfda0..cf1366e59 100644 --- a/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts +++ b/frontend/src/pages/ManagerJoin/ManagerJoin.styles.ts @@ -13,14 +13,6 @@ export const PageTitle = styled.h2` margin: 2.125rem auto; `; -export const Form = styled.form` - margin: 3.75rem 0; - - label { - margin-bottom: 3rem; - } -`; - export const JoinLinkMessage = styled.p` margin: 1rem 0; text-align: center; diff --git a/frontend/src/pages/ManagerJoin/ManagerJoin.tsx b/frontend/src/pages/ManagerJoin/ManagerJoin.tsx index 72e2dd39e..9e2671da3 100644 --- a/frontend/src/pages/ManagerJoin/ManagerJoin.tsx +++ b/frontend/src/pages/ManagerJoin/ManagerJoin.tsx @@ -1,49 +1,26 @@ import { AxiosError } from 'axios'; -import { FormEventHandler, useEffect, useState } from 'react'; -import { useMutation, useQuery } from 'react-query'; -import { Link, useHistory } from 'react-router-dom'; -import { postJoin, queryValidateEmail } from 'api/join'; -import Button from 'components/Button/Button'; +import React from 'react'; +import { useMutation } from 'react-query'; +import { useHistory } from 'react-router'; +import { Link } from 'react-router-dom'; +import { postJoin } from 'api/join'; import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; import Layout from 'components/Layout/Layout'; -import MANAGER from 'constants/manager'; import MESSAGE from 'constants/message'; import PATH from 'constants/path'; -import REGEXP from 'constants/regexp'; -import useInput from 'hooks/useInput'; import { ErrorResponse } from 'types/response'; import * as Styled from './ManagerJoin.styles'; +import JoinForm from './units/JoinForm'; -const ManagerJoin = (): JSX.Element => { - const [email, onChangeEmail] = useInput(''); - const [password, onChangePassword] = useInput(''); - const [passwordConfirm, onChangePasswordConfirm] = useInput(''); - const [organization, onChangeOrganization] = useInput(''); - - const [emailMessage, setEmailMessage] = useState(''); - const [passwordMessage, setPasswordMessage] = useState(''); - const [passwordConfirmMessage, setPasswordConfirmMessage] = useState(''); - const [organizationMessage, setOrganizationMessage] = useState(''); +export interface JoinParams { + email: string; + password: string; + organization: string; +} +const ManagerJoin = (): JSX.Element => { const history = useHistory(); - const isValidPassword = REGEXP.PASSWORD.test(password); - const isValidOrganization = REGEXP.ORGANIZATION.test(organization); - - const isValidEmail = useQuery(['isValidEmail', email], queryValidateEmail, { - enabled: false, - retry: false, - - onSuccess: () => { - setEmailMessage(MESSAGE.JOIN.VALID_EMAIL); - }, - - onError: (error: AxiosError) => { - setEmailMessage(error.response?.data.message ?? ''); - }, - }); - const join = useMutation(postJoin, { onSuccess: () => { alert(MESSAGE.JOIN.SUCCESS); @@ -55,116 +32,23 @@ const ManagerJoin = (): JSX.Element => { }, }); - const handleValidateEmail = () => { - if (!email) return; - - isValidEmail.refetch(); - }; - - const handleSubmitJoinForm: FormEventHandler = (event) => { - event.preventDefault(); - - if (!email || !password || !passwordConfirm || !organization) return; - - if (password !== passwordConfirm) { - alert(MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM); - - return; - } + const handleSubmit = ({ email, password, organization }: JoinParams) => { + if (!email || !password || !organization) return; join.mutate({ email, password, organization }); }; - useEffect(() => { - if (!password) return; - - !isValidPassword - ? setPasswordMessage(MESSAGE.JOIN.INVALID_PASSWORD) - : setPasswordMessage(MESSAGE.JOIN.VALID_PASSWORD); - }, [password, isValidPassword]); - - useEffect(() => { - if (!password || !passwordConfirm) return; - - password !== passwordConfirm - ? setPasswordConfirmMessage(MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM) - : setPasswordConfirmMessage(MESSAGE.JOIN.VALID_PASSWORD_CONFIRM); - }, [password, passwordConfirm]); - - useEffect(() => { - if (!organization) { - setOrganizationMessage(''); - return; - } - - !isValidOrganization - ? setOrganizationMessage(MESSAGE.JOIN.INVALID_ORGANIZATION) - : setOrganizationMessage(MESSAGE.JOIN.VALID_ORGANIZATION); - }, [organization, isValidOrganization]); - return ( <>
회원가입 - - - - - - - - 이미 회원이신가요? - 로그인하기 - - + + + 이미 회원이신가요? + 로그인하기 + diff --git a/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts b/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts new file mode 100644 index 000000000..32813ffb5 --- /dev/null +++ b/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const Form = styled.form` + margin: 3.75rem 0 1rem; + + label { + margin-bottom: 3rem; + } +`; diff --git a/frontend/src/pages/ManagerJoin/units/JoinForm.tsx b/frontend/src/pages/ManagerJoin/units/JoinForm.tsx new file mode 100644 index 000000000..49c4eb165 --- /dev/null +++ b/frontend/src/pages/ManagerJoin/units/JoinForm.tsx @@ -0,0 +1,164 @@ +import { AxiosError } from 'axios'; +import React, { FormEventHandler, useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; +import { queryValidateEmail } from 'api/join'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MANAGER from 'constants/manager'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import useInputs from 'hooks/useInputs'; +import { ErrorResponse } from 'types/response'; +import { JoinParams } from '../ManagerJoin'; +import * as Styled from './JoinForm.styles'; + +interface Form { + email: string; + password: string; + passwordConfirm: string; + organization: string; +} + +interface Props { + onSubmit: ({ email, password, organization }: JoinParams) => void; +} + +const JoinForm = ({ onSubmit }: Props): JSX.Element => { + const [{ email, password, passwordConfirm, organization }, onChangeForm] = useInputs({ + email: '', + password: '', + passwordConfirm: '', + organization: '', + }); + + const [emailMessage, setEmailMessage] = useState(''); + const [passwordMessage, setPasswordMessage] = useState(''); + const [passwordConfirmMessage, setPasswordConfirmMessage] = useState(''); + const [organizationMessage, setOrganizationMessage] = useState(''); + + const isValidPassword = REGEXP.PASSWORD.test(password); + const isValidOrganization = REGEXP.ORGANIZATION.test(organization); + + const checkValidateEmail = useQuery(['checkValidateEmail', email], queryValidateEmail, { + enabled: false, + retry: false, + + onSuccess: () => { + setEmailMessage(MESSAGE.JOIN.VALID_EMAIL); + }, + + onError: (error: AxiosError) => { + setEmailMessage(error.response?.data.message ?? ''); + }, + }); + + const handleValidateEmail = () => { + if (!email) return; + + checkValidateEmail.refetch(); + }; + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + if (password !== passwordConfirm) { + alert(MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM); + + return; + } + + onSubmit({ email, password, organization }); + }; + + useEffect(() => { + if (!password) return; + + setPasswordMessage( + isValidPassword ? MESSAGE.JOIN.VALID_PASSWORD : MESSAGE.JOIN.INVALID_PASSWORD + ); + }, [password, isValidPassword]); + + useEffect(() => { + if (!password || !passwordConfirm) return; + + setPasswordConfirmMessage( + password === passwordConfirm + ? MESSAGE.JOIN.VALID_PASSWORD_CONFIRM + : MESSAGE.JOIN.INVALID_PASSWORD_CONFIRM + ); + }, [password, passwordConfirm]); + + useEffect(() => { + if (!organization) { + setOrganizationMessage(''); + + return; + } + + setOrganizationMessage( + isValidOrganization ? MESSAGE.JOIN.VALID_ORGANIZATION : MESSAGE.JOIN.INVALID_ORGANIZATION + ); + }, [organization, isValidOrganization]); + + return ( + + + + + + + + ); +}; + +export default JoinForm; diff --git a/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts b/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts index ec42bfda0..b319e03d9 100644 --- a/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts +++ b/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts @@ -13,16 +13,7 @@ export const PageTitle = styled.h2` margin: 2.125rem auto; `; -export const Form = styled.form` - margin: 3.75rem 0; - - label { - margin-bottom: 3rem; - } -`; - export const JoinLinkMessage = styled.p` - margin: 1rem 0; text-align: center; font-size: 0.75rem; color: ${({ theme }) => theme.gray[500]}; diff --git a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx index 67f93210c..d41eb8930 100644 --- a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx +++ b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx @@ -1,39 +1,40 @@ import { AxiosError, AxiosResponse } from 'axios'; -import { FormEventHandler, useState } from 'react'; +import { useState } from 'react'; import { useMutation } from 'react-query'; -import { Link, useHistory } from 'react-router-dom'; +import { useHistory } from 'react-router'; +import { Link } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; import { postLogin } from 'api/login'; -import Button from 'components/Button/Button'; import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; import Layout from 'components/Layout/Layout'; -import MANAGER from 'constants/manager'; import MESSAGE from 'constants/message'; import PATH from 'constants/path'; import { LOCAL_STORAGE_KEY } from 'constants/storage'; -import useInput from 'hooks/useInput'; import accessTokenState from 'state/accessTokenState'; import { ErrorResponse, LoginSuccess } from 'types/response'; import { setLocalStorageItem } from 'utils/localStorage'; import * as Styled from './ManagerLogin.styles'; +import LoginForm from './units/LoginForm'; -interface ErrorMessage { +export interface ErrorMessage { email?: string; password?: string; } +export interface LoginParams { + email: string; + password: string; +} + const ManagerLogin = (): JSX.Element => { - const [email, onChangeEmail] = useInput(''); - const [password, onChangePassword] = useInput(''); + const history = useHistory(); + const setAccessToken = useSetRecoilState(accessTokenState); + const [errorMessage, setErrorMessage] = useState({ email: '', password: '', }); - const setAccessToken = useSetRecoilState(accessTokenState); - const history = useHistory(); - const login = useMutation(postLogin, { onSuccess: (response: AxiosResponse) => { const { accessToken } = response.data; @@ -56,9 +57,7 @@ const ManagerLogin = (): JSX.Element => { }, }); - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - + const handleSubmit = ({ email, password }: LoginParams) => { if (!(email && password)) return; login.mutate({ email, password }); @@ -70,36 +69,11 @@ const ManagerLogin = (): JSX.Element => { 로그인 - - - - - - 아직 회원이 아니신가요? - 회원가입하기 - - + + + 아직 회원이 아니신가요? + 회원가입하기 + diff --git a/frontend/src/pages/ManagerLogin/units/LoginForm.styles.ts b/frontend/src/pages/ManagerLogin/units/LoginForm.styles.ts new file mode 100644 index 000000000..32813ffb5 --- /dev/null +++ b/frontend/src/pages/ManagerLogin/units/LoginForm.styles.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const Form = styled.form` + margin: 3.75rem 0 1rem; + + label { + margin-bottom: 3rem; + } +`; diff --git a/frontend/src/pages/ManagerLogin/units/LoginForm.tsx b/frontend/src/pages/ManagerLogin/units/LoginForm.tsx new file mode 100644 index 000000000..6d04f27af --- /dev/null +++ b/frontend/src/pages/ManagerLogin/units/LoginForm.tsx @@ -0,0 +1,64 @@ +import { FormEventHandler } from 'react'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MANAGER from 'constants/manager'; +import useInputs from 'hooks/useInputs'; +import { ErrorMessage, LoginParams } from '../ManagerLogin'; + +import * as Styled from './LoginForm.styles'; + +interface Form { + email: string; + password: string; +} + +interface Props { + errorMessage: ErrorMessage; + onSubmit: ({ email, password }: LoginParams) => void; +} + +const LoginForm = ({ errorMessage, onSubmit }: Props): JSX.Element => { + const [{ email, password }, onChangeForm] = useInputs({ + email: '', + password: '', + }); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + onSubmit({ email, password }); + }; + + return ( + + + + + + ); +}; + +export default LoginForm; From 187d54b75a7dc8150ea392338e62d0e11706d887 Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Mon, 13 Sep 2021 16:06:45 +0900 Subject: [PATCH 03/25] =?UTF-8?q?refactor:=20=EB=A7=B5=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20(#524)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: `ManagerMapCreate`에서 컴포넌트 및 커스텀 훅으로 로직 분리 - `MapCreateEditor`: 툴바와 `Board`를 포함하고 있는 유닛 컴포넌트 - `Board`: SVG로 구현된 보드 영역 관련 컴포넌트 - `GridPattern`: SVG로 구현된 격자 패턴 정의 및 패턴을 fill한 `rect` 컴포넌트 - `useBoardCoordinate`: `Board`에서 커서의 좌표값을 가져오는 커스텀 훅 - `useBindKeyPress`: 입력한 키를 바인드하는 범용적인 커스텀 훅 - `useBoardMove`: `Board` 컴포넌트에서 보드 영역을 움직이는 로직을 가진 커스텀 훅 - `useBoardZoom`: `Board` 컴포넌트애서 보드 영역을 확대 축소하는 로직을 가진 커스텀 훅 * refactor: `Board`에서 사용하는 여러 로직을 커스텀 훅으로 분리 - `Board`에서 선 그리기 관련 로직을 `useBoardLineTool` 커스텀 훅으로 분리 - 좌표값, 가로, 세로 사이즈 등 `Board`의 상태와 속성 관련 로직을 `useBoardStatus` 커스텀 훅으로 분리 - `Board` 컴포넌트가 내부적으로 가지고 있는 커스텀 훅 로직을 부모 컴포넌트로 옮겨서 적용 * refactor: 사각형 그리기 로직을 `useBoardRectTool` 커스텀 훅으로 분리 * refactor: 지우개 기능 로직을 `useBoardEraserTool` 로 분리 * refactor: `MapCreateEditor`에서 마우스 핸들러의 조건부 실행 로직에 `else` 추가 * refactor: 맵 요소 선택 기능 로직을 `useBoardSelect` 커스텀 훅으로 분리 * refactor: 맵 생성 및 수정 API 요청 로직 적용 - `getMapImageSvg` 유틸 함수 분리 * fix: Board의 사이즈가 바뀔 때마다 가운데로 정렬되지 않는 문제 수정 - Board를 가운데로 정렬하는 로직을 `useEffect`가 아닌 `useLayoutEffect`를 사용하도록 수정 * chore: 파일 디렉토리 경로 및 컴포넌트 이름 재구성 * fix: 이미 API 요청을 보내는 중일 때, 추가 및 수정 버튼을 눌러도 API가 요청되지 않도록 수정 * fix: 맵 추가 페이지에서 메인으로 돌아왔을 때 예약 목록이 로드되지 않는 문제 수정 * refactor: `onSelectErasingElement` -> `selectErasingElement` 메소드명 변경 * refactor: `MapEditor` 컴포넌트에서 지우개 기능 로직을 `mouseover` 이벤트 핸들러에서 적용하도록 수정 * fix: 맵을 수정 요청 에러 발생 시, alert message가 보이도록 수정 * refactor: `JSON.parse`를 사용하는 코드에 try catch 문 적용 * refactor: `handleClickCancel` -> `handleCancel`로 네이밍 변경 * refactor: `getMapImageSvg` -> `createMapImageSvg`로 네이밍 변경 * refactor: 맵 요소에서 사용하는 리터럴 상수화 * refactor: `useBoardSelect` 커스텀 훅에서 클릭 이벤트 핸들러까지만 반환하도록 수정 * refactor: `useBoardEraserTool` 커스텀 훅에서 `mouseover` 이벤트 핸들러까지만 반환하도록 수정 * refactor: `MapElement['type']`을 `MapElementType` enum으로 정의하여 적용 * refactor: `` 요소에 정의된 `SVGElement` 타입을 `SVGSVGElement`로 변경 * refactor: `useBoardSelect` 에서 `React` 네임스페이스 명시 --- frontend/src/constants/editor.ts | 9 + frontend/src/constants/message.ts | 2 + frontend/src/constants/routes.tsx | 6 +- .../ManagerMapCreate/ManagerMapCreate.tsx | 939 ------------------ .../ManagerMapEditor.styles.ts | 48 + .../ManagerMapEditor/ManagerMapEditor.tsx | 172 ++++ .../ManagerMapEditor/hooks/useBindKeyPress.ts | 27 + .../hooks/useBoardCoordinate.ts | 42 + .../hooks/useBoardEraserTool.ts | 60 ++ .../hooks/useBoardLineTool.ts | 74 ++ .../ManagerMapEditor/hooks/useBoardMove.ts | 62 ++ .../hooks/useBoardRectTool.ts | 105 ++ .../ManagerMapEditor/hooks/useBoardSelect.ts | 92 ++ .../ManagerMapEditor/hooks/useBoardStatus.ts | 32 + .../ManagerMapEditor/hooks/useBoardZoom.ts | 51 + .../ManagerMapEditor/units/Board.styles.ts | 16 + .../pages/ManagerMapEditor/units/Board.tsx | 93 ++ .../ManagerMapEditor/units/GridPattern.tsx | 51 + .../units/MapEditor.styles.ts} | 139 +-- .../ManagerMapEditor/units/MapEditor.tsx | 325 ++++++ frontend/src/types/common.ts | 3 +- frontend/src/types/editor.ts | 5 + frontend/src/utils/map.ts | 71 ++ 23 files changed, 1369 insertions(+), 1055 deletions(-) delete mode 100644 frontend/src/pages/ManagerMapCreate/ManagerMapCreate.tsx create mode 100644 frontend/src/pages/ManagerMapEditor/ManagerMapEditor.styles.ts create mode 100644 frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBindKeyPress.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardCoordinate.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardEraserTool.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardMove.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardStatus.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardZoom.ts create mode 100644 frontend/src/pages/ManagerMapEditor/units/Board.styles.ts create mode 100644 frontend/src/pages/ManagerMapEditor/units/Board.tsx create mode 100644 frontend/src/pages/ManagerMapEditor/units/GridPattern.tsx rename frontend/src/pages/{ManagerMapCreate/ManagerMapCreate.styles.ts => ManagerMapEditor/units/MapEditor.styles.ts} (53%) create mode 100644 frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx create mode 100644 frontend/src/utils/map.ts diff --git a/frontend/src/constants/editor.ts b/frontend/src/constants/editor.ts index 337e5dd9d..700f99d91 100644 --- a/frontend/src/constants/editor.ts +++ b/frontend/src/constants/editor.ts @@ -43,4 +43,13 @@ export const EDITOR = { MIN_SCALE: 0.5, MAX_SCALE: 3.0, STROKE_WIDTH: 3, + STROKE_PREVIEW: PALETTE.OPACITY_BLACK[200], + OPACITY: 1, + OPACITY_DELETING: 0.3, + TEXT_OPACITY: 0.3, + TEXT_FONT_SIZE: '1rem', + TEXT_FILL: PALETTE.BLACK[700], + SPACE_OPACITY: 0.1, + CIRCLE_CURSOR_RADIUS: 3, + CIRCLE_CURSOR_FILL: PALETTE.OPACITY_BLACK[300], }; diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index d44ab3e2b..a79115ed9 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -66,6 +66,8 @@ const MESSAGE = { CANCEL_CONFIRM: '편집 중인 맵은 저장되지 않으며, 메인 페이지로 돌아갑니다.', UNEXPECTED_MAP_CREATE_ERROR: '맵을 생성하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', + UNEXPECTED_MAP_UPDATE_ERROR: + '맵을 수정하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', }, }; diff --git a/frontend/src/constants/routes.tsx b/frontend/src/constants/routes.tsx index 6ecdefa3c..ddbc63063 100644 --- a/frontend/src/constants/routes.tsx +++ b/frontend/src/constants/routes.tsx @@ -5,7 +5,7 @@ import Main from 'pages/Main/Main'; import ManagerJoin from 'pages/ManagerJoin/ManagerJoin'; import ManagerLogin from 'pages/ManagerLogin/ManagerLogin'; import ManagerMain from 'pages/ManagerMain/ManagerMain'; -import ManagerMapCreate from 'pages/ManagerMapCreate/ManagerMapCreate'; +import ManagerMapEditor from 'pages/ManagerMapEditor/ManagerMapEditor'; import ManagerReservationEdit from 'pages/ManagerReservationEdit/ManagerReservationEdit'; import ManagerSpaceEdit from 'pages/ManagerSpaceEdit/ManagerSpaceEdit'; import PATH from './path'; @@ -59,12 +59,12 @@ export const PRIVATE_ROUTES: PrivateRoute[] = [ }, { path: PATH.MANAGER_MAP_CREATE, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, { path: PATH.MANAGER_MAP_EDIT, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, { diff --git a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.tsx b/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.tsx deleted file mode 100644 index 9038acdad..000000000 --- a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.tsx +++ /dev/null @@ -1,939 +0,0 @@ -import { AxiosError } from 'axios'; -import { - FocusEventHandler, - FormEventHandler, - MouseEvent, - MouseEventHandler, - useCallback, - useEffect, - useMemo, - useRef, - useState, - WheelEventHandler, -} from 'react'; -import { useMutation } from 'react-query'; -import { useHistory, useParams } from 'react-router-dom'; -import { postMap, putMap } from 'api/managerMap'; -import { ReactComponent as EraserIcon } from 'assets/svg/eraser.svg'; -import { ReactComponent as ItemsIcon } from 'assets/svg/items.svg'; -import { ReactComponent as LineIcon } from 'assets/svg/line.svg'; -import { ReactComponent as MoveIcon } from 'assets/svg/move.svg'; -import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; -import { ReactComponent as SelectIcon } from 'assets/svg/select.svg'; -import Button from 'components/Button/Button'; -import ColorPicker from 'components/ColorPicker/ColorPicker'; -import ColorPickerIcon from 'components/ColorPicker/ColorPickerIcon'; -import Header from 'components/Header/Header'; -import Layout from 'components/Layout/Layout'; -import { BOARD, EDITOR, KEY } from 'constants/editor'; -import MESSAGE from 'constants/message'; -import PALETTE from 'constants/palette'; -import PATH, { HREF } from 'constants/path'; -import useInput from 'hooks/useInput'; -import useListenManagerMainState from 'hooks/useListenManagerMainState'; -import useManagerMap from 'hooks/useManagerMap'; -import useManagerSpaces from 'hooks/useManagerSpaces'; -import { - Color, - Coordinate, - DrawingStatus, - EditorBoard, - GripPoint, - ManagerSpace, - MapDrawing, - MapElement, - SpaceArea, -} from 'types/common'; -import { Mode } from 'types/editor'; -import { ErrorResponse } from 'types/response'; -import * as Styled from './ManagerMapCreate.styles'; - -interface Params { - mapId?: string; -} - -const ManagerMapCreate = (): JSX.Element => { - const editorRef = useRef(null); - - const history = useHistory(); - const params = useParams(); - const mapId = params?.mapId; - const isEdit = !!mapId; - - useListenManagerMainState({ mapId: Number(mapId) }, { enabled: isEdit }); - - const [mapName, onChangeMapName, setMapName] = useInput(''); - - const [mode, setMode] = useState(Mode.Select); - const [dragOffsetX, setDragOffsetX] = useState(0); - const [dragOffsetY, setDragOffsetY] = useState(0); - const [isDragging, setDragging] = useState(false); - const [isPressSpacebar, setPressSpacebar] = useState(false); - const isDraggable = mode === Mode.Move || isPressSpacebar; - - const [color, setColor] = useState(PALETTE.BLACK[400]); - const [colorPickerOpen, setColorPickerOpen] = useState(false); - - const [coordinate, setCoordinate] = useState({ x: 0, y: 0 }); - - const [stickyPointerView, setStickyPointerView] = useState(false); - - const stickyCoordinate: Coordinate = { - x: Math.round(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - y: Math.round(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - }; - - const [drawingStatus, setDrawingStatus] = useState({}); - const [mapElements, setMapElements] = useState([]); - - const [gripPoints, setGripPoints] = useState([]); - const [selectedMapElementId, setSelectedMapElementId] = useState(null); - const [erasingMapElementIds, setErasingMapElementIds] = useState([]); - const [isErasing, setErasing] = useState(false); - - const nextMapElementId = Math.max(...mapElements.map(({ id }) => id), 1) + 1; - const nextGripPointId = Math.max(...gripPoints.map(({ id }) => id), 1) + 1; - - const [widthValue, onChangeWidthValue, setWidthValue] = useInput('800'); - const [heightValue, onChangeHeightValue, setHeightValue] = useInput('600'); - - const width = Number(widthValue); - const height = Number(heightValue); - - const [board, setBoard] = useState({ - width, - height, - x: 0, - y: 0, - scale: 1, - }); - - const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }, { enabled: isEdit }); - const spaces: ManagerSpace[] = useMemo( - () => - managerSpaces.data?.data.spaces.map((space) => ({ - ...space, - area: JSON.parse(space.area) as SpaceArea, - })) ?? [], - [managerSpaces.data?.data.spaces] - ); - - const managerMap = useManagerMap( - { mapId: Number(mapId) }, - { - enabled: isEdit, - onSuccess: ({ data }) => { - const { mapName, mapDrawing } = data; - - setMapName(mapName ?? ''); - - try { - const { mapElements, width, height } = JSON.parse(mapDrawing) as MapDrawing; - - setMapElements(mapElements); - setWidthValue(`${width}`); - setHeightValue(`${height}`); - } catch (error) { - console.error(error); - setMapElements([]); - } - }, - } - ); - - const createMap = useMutation(postMap, { - onSuccess: (response) => { - if (window.confirm(MESSAGE.MANAGER_MAP.CREATE_SUCCESS_CONFIRM)) { - const headers = response.headers as { location: string }; - const mapId = Number(headers.location.split('/').pop()); - - history.push(HREF.MANAGER_SPACE_EDIT(mapId)); - - return; - } - - history.push(PATH.MANAGER_MAIN); - }, - onError: (error: AxiosError) => { - alert(error?.response?.data.message ?? MESSAGE.MANAGER_MAP.UNEXPECTED_MAP_CREATE_ERROR); - }, - }); - - const updateMap = useMutation(putMap, { - onSuccess: () => { - alert(MESSAGE.MANAGER_MAP.UPDATE_SUCCESS); - }, - onError: (error: AxiosError) => { - console.error(error); - }, - }); - - const getSVGCoordinate = (event: MouseEvent) => { - const svg = (event.nativeEvent.target as SVGElement)?.ownerSVGElement; - if (!svg) return { svg: null, x: -1, y: -1 }; - - let point = svg.createSVGPoint(); - - point.x = event.nativeEvent.clientX; - point.y = event.nativeEvent.clientY; - point = point.matrixTransform(svg.getScreenCTM()?.inverse()); - - const x = (point.x - board.x) * (1 / board.scale); - const y = (point.y - board.y) * (1 / board.scale); - - return { svg, x, y }; - }; - - const selectMode = (mode: Mode) => { - setDrawingStatus({}); - setCoordinate({ x: 0, y: 0 }); - setMode(mode); - }; - - const unselectMapElement = () => { - setSelectedMapElementId(null); - setGripPoints([]); - }; - - const handleMouseMove: MouseEventHandler = (event) => { - const { x, y } = getSVGCoordinate(event); - setCoordinate({ x, y }); - }; - - const handleWheel: WheelEventHandler = (event) => { - const { offsetX, offsetY, deltaY } = event.nativeEvent; - - setBoard((prevState) => { - const { scale, x, y, width, height } = prevState; - - const nextScale = scale - deltaY * EDITOR.SCALE_DELTA; - - if (nextScale <= EDITOR.MIN_SCALE || nextScale >= EDITOR.MAX_SCALE) { - return { - ...prevState, - scale: prevState.scale, - }; - } - - const cursorX = (offsetX - x) / (width * scale); - const cursorY = (offsetY - y) / (height * scale); - - const widthDiff = Math.abs(width * nextScale - width * scale) * cursorX; - const heightDiff = Math.abs(height * nextScale - height * scale) * cursorY; - - const nextX = nextScale > scale ? x - widthDiff : x + widthDiff; - const nextY = nextScale > scale ? y - heightDiff : y + heightDiff; - - return { - ...prevState, - x: nextX, - y: nextY, - scale: nextScale, - }; - }); - }; - - const handleDragStart: MouseEventHandler = (event) => { - if (!isDraggable) return; - - setDragOffsetX(event.nativeEvent.offsetX - board.x); - setDragOffsetY(event.nativeEvent.offsetY - board.y); - - setDragging(true); - }; - - const handleDrag: MouseEventHandler = (event) => { - if (!isDraggable || !isDragging) return; - - const { offsetX, offsetY } = event.nativeEvent; - - setBoard((prevState) => ({ - ...prevState, - x: offsetX - dragOffsetX, - y: offsetY - dragOffsetY, - })); - }; - - const handleDragEnd = () => { - if (!isDraggable) return; - - setDragOffsetX(0); - setDragOffsetY(0); - - setDragging(false); - }; - - const handleMouseOut = () => { - setDragging(false); - }; - - const handleSelectLineElement = (event: MouseEvent, id: MapElement['id']) => { - if (mode !== Mode.Select) return; - - const target = event.target as SVGPolylineElement; - const points = Object.values(target?.points).map(({ x, y }) => ({ x, y })); - - const newGripPoints = points.map( - (point, index): GripPoint => ({ - id: nextGripPointId + index, - mapElementId: id, - x: point.x, - y: point.y, - }) - ); - - setSelectedMapElementId(id); - setGripPoints([...newGripPoints]); - }; - - const handleSelectRectElement = (event: MouseEvent, id: MapElement['id']) => { - if (mode !== Mode.Select) return; - - const target = event.target as SVGRectElement; - - const { x, y, width, height } = target; - - const pointX = x.baseVal.value; - const pointY = y.baseVal.value; - const widthValue = width.baseVal.value; - const heightValue = height.baseVal.value; - - const points = [ - { x: pointX, y: pointY }, - { x: pointX + widthValue, y: pointY }, - { x: pointX, y: pointY + heightValue }, - { x: pointX + widthValue, y: pointY + heightValue }, - ]; - - const newGripPoints = points.map( - (point, index): GripPoint => ({ - id: nextGripPointId + index, - mapElementId: id, - x: point.x, - y: point.y, - }) - ); - - setSelectedMapElementId(id); - setGripPoints([...newGripPoints]); - }; - - const handleClickBoard: MouseEventHandler = (event) => { - unselectMapElement(); - }; - - const drawStart = () => { - if (drawingStatus.start) { - const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; - const endPoint = `${stickyCoordinate.x},${stickyCoordinate.y}`; - - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'polyline', - stroke: color, - points: [startPoint, endPoint], - }, - ]); - - return; - } - - if (isDragging) return; - - setDrawingStatus((prevState) => ({ - ...prevState, - start: stickyCoordinate, - })); - }; - - const drawEnd = () => { - if (!drawingStatus || !drawingStatus.start) return; - - const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; - const endPoint = `${stickyCoordinate.x},${stickyCoordinate.y}`; - - setDrawingStatus({}); - - if (startPoint === endPoint || isDragging) return; - - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'polyline', - stroke: color, - points: [startPoint, endPoint], - }, - ]); - }; - - const rectDrawStart = () => { - if (drawingStatus.start) { - const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; - const endPoint = `${stickyCoordinate.x},${stickyCoordinate.y}`; - - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'rect', - stroke: color, - points: [startPoint, endPoint], - }, - ]); - - return; - } - - if (isDragging) return; - - setDrawingStatus((prevState) => ({ - ...prevState, - start: stickyCoordinate, - })); - }; - - const rectDrawEnd = () => { - if (!drawingStatus || !drawingStatus.start) return; - - const startPoint = { - x: drawingStatus.start.x, - y: drawingStatus.start.y, - }; - - const endPoint = { - x: stickyCoordinate.x, - y: stickyCoordinate.y, - }; - - const width = Math.abs(startPoint.x - endPoint.x); - const height = Math.abs(startPoint.y - endPoint.y); - - const startCoordinate = `${startPoint.x}, ${startPoint.y}`; - const endCoordinate = `${endPoint.x}, ${endPoint.y}`; - - setDrawingStatus({}); - - if (startCoordinate === endCoordinate || isDragging) return; - - if (width && height) { - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'rect', - stroke: color, - width, - height, - x: Math.min(startPoint.x, endPoint.x), - y: Math.min(startPoint.y, endPoint.y), - points: [startCoordinate, endCoordinate], - }, - ]); - } else { - setMapElements((prevState) => [ - ...prevState, - { - id: nextMapElementId, - type: 'polyline', - stroke: color, - points: [`${startPoint.x},${startPoint.y}`, `${endPoint.x},${endPoint.y}`], - }, - ]); - } - }; - - const eraseStart = () => { - if (erasingMapElementIds.length > 0) { - eraseEnd(); - - return; - } - setErasing(true); - setErasingMapElementIds([]); - }; - - const eraseEnd = () => { - setErasing(false); - setMapElements((prevMapElements) => - prevMapElements.filter(({ id }) => !erasingMapElementIds.includes(id)) - ); - setErasingMapElementIds([]); - }; - - const handleSelectErasingElement = (id: MapElement['id']) => { - if (mode !== Mode.Eraser || !isErasing) return; - - setErasingMapElementIds((prevIds) => [...prevIds, id]); - }; - - const handleMouseDown = () => { - if (isDraggable) return; - - if (mode === Mode.Line) drawStart(); - if (mode === Mode.Rect) rectDrawStart(); - if (mode === Mode.Eraser) eraseStart(); - }; - - const handleMouseUp = () => { - if (isDraggable) return; - - if (mode === Mode.Line) drawEnd(); - if (mode === Mode.Rect) rectDrawEnd(); - if (mode === Mode.Eraser) eraseEnd(); - }; - - const deleteMapElement = useCallback(() => { - if (!selectedMapElementId) return; - - setMapElements((prevMapElements) => - prevMapElements.filter(({ id }) => id !== selectedMapElementId) - ); - unselectMapElement(); - }, [selectedMapElementId]); - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if ((event.target as HTMLElement).tagName === 'INPUT') return; - - if (event.key === KEY.DELETE || event.key === KEY.BACK_SPACE) { - deleteMapElement(); - } - if (event.key === KEY.SPACE) { - setPressSpacebar(true); - } - }, - [deleteMapElement] - ); - - const handleKeyUp = useCallback((event: KeyboardEvent) => { - if (event.key === KEY.SPACE) { - setPressSpacebar(false); - } - }, []); - - const handleClickCancel = () => { - if (!window.confirm(MESSAGE.MANAGER_MAP.CANCEL_CONFIRM)) return; - - history.push(PATH.MANAGER_MAIN); - }; - - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - - const mapDrawing = JSON.stringify({ width, height, mapElements }); - - const mapImageSvg = ` - - ${spaces - ?.map( - ({ color, area }) => ` - - - ` - ) - .join('')} - - ${mapElements - .map((element) => - element.type === 'polyline' - ? ` - - ` - : ` - - ` - ) - .join('')} - - ` - .replace(/(\r\n\t|\n|\r\t|\s{1,})/gm, ' ') - .replace(/\s{2,}/g, ' '); - - if (isEdit) { - updateMap.mutate({ mapId: Number(mapId), mapName, mapDrawing, mapImageSvg }); - return; - } - - createMap.mutate({ mapName, mapDrawing, mapImageSvg }); - }; - - const handleWidthSize: FocusEventHandler = (event) => { - if (width > BOARD.MAX_WIDTH) { - event.target.value = String(BOARD.MAX_WIDTH); - onChangeWidthValue(event); - } - - if (width < BOARD.MIN_WIDTH) { - event.target.value = String(BOARD.MIN_WIDTH); - onChangeWidthValue(event); - } - }; - - const handleHeightSize: FocusEventHandler = (event) => { - if (height > BOARD.MAX_HEIGHT) { - event.target.value = String(BOARD.MAX_HEIGHT); - onChangeHeightValue(event); - } - - if (height < BOARD.MIN_HEIGHT) { - event.target.value = String(BOARD.MIN_HEIGHT); - onChangeHeightValue(event); - } - }; - - useEffect(() => { - const editorWidth = editorRef.current ? editorRef.current.offsetWidth : 0; - const editorHeight = editorRef.current ? editorRef.current.offsetHeight : 0; - - setBoard((prevState) => ({ - ...prevState, - x: (editorWidth - width) / 2, - y: (editorHeight - height) / 2, - })); - }, [width, height]); - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('keyup', handleKeyUp); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('keyup', handleKeyUp); - }; - }, [handleKeyDown, handleKeyUp]); - - return ( - <> - -
- - - - - - - - - {/* NOTE 추후 임시저장 기능 구현 시, 이 부분의 주석을 해제하고 작성하면 됩니다. */} - {/* - 1분 전에 임시 저장되었습니다. - - 임시 저장 - - */} - - - - - - - - - - - - selectMode(Mode.Select)} - > - - - selectMode(Mode.Move)} - > - - - selectMode(Mode.Line)} - > - - - selectMode(Mode.Rect)} - > - - - selectMode(Mode.Eraser)} - > - - - - {/* NOTE 추후 장식 기능 구현 시, 이 부분의 주석을 해제하고 작성하면 됩니다. */} - {/* selectMode(Mode.Decoration)} - > - - */} - - setColorPickerOpen(!colorPickerOpen)} - > - - - - - - - - - - - - - - - - - - - - setStickyPointerView(true)} - onMouseLeave={() => setStickyPointerView(false)} - > - - - {/* 전체 격자를 그리는 rect */} - - - {[Mode.Line, Mode.Rect].includes(mode) && stickyPointerView && ( - - )} - - {/* Note: 공간 영역 */} - {spaces.map(({ id, color, area, name }) => ( - - - - {name} - - - ))} - - {mapElements.map((element) => - element.type === 'polyline' ? ( - handleSelectLineElement(event, element.id)} - onMouseOverCapture={() => handleSelectErasingElement(element.id)} - /> - ) : ( - handleSelectRectElement(event, element.id)} - onMouseOverCapture={() => handleSelectErasingElement(element.id)} - /> - ) - )} - - {mode === Mode.Select && - gripPoints.map(({ x, y }, index) => ( - - ))} - - {drawingStatus.start && mode === Mode.Line && ( - - )} - - {drawingStatus.start && - mode === Mode.Rect && - (Math.abs(drawingStatus.start.x - stickyCoordinate.x) && - Math.abs(drawingStatus.start.y - stickyCoordinate.y) ? ( - - ) : ( - - ))} - - - - - - - - W - 넓이 - - - - - - H - 높이 - - - - - - - - - ); -}; - -export default ManagerMapCreate; diff --git a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.styles.ts b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.styles.ts new file mode 100644 index 000000000..f40355dd2 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.styles.ts @@ -0,0 +1,48 @@ +import styled, { createGlobalStyle } from 'styled-components'; + +export const MapCreateGlobalStyle = createGlobalStyle` + body { + overscroll-behavior: none; + } +`; + +export const Container = styled.div` + padding: 2rem 0; + display: flex; + flex-direction: column; + height: calc(100vh - 3rem); +`; + +export const Form = styled.form` + display: flex; + flex-direction: column; + flex: 1; +`; + +export const FormHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 0.75rem; +`; + +export const FormControl = styled.div` + display: flex; + gap: 0.5rem; +`; + +export const MapNameInput = styled.input` + border: none; + border-radius: 0.125rem; + font-size: 1.5rem; + display: inline-block; + border: 2px solid transparent; + + &:hover { + border-color: ${({ theme }) => theme.primary[400]}; + } + + &:focus { + outline-color: ${({ theme }) => theme.primary[400]}; + } +`; diff --git a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx new file mode 100644 index 000000000..ad4a8f718 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx @@ -0,0 +1,172 @@ +import { AxiosError } from 'axios'; +import React, { useMemo, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useHistory, useParams } from 'react-router'; +import { postMap, putMap } from 'api/managerMap'; +import Button from 'components/Button/Button'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import MESSAGE from 'constants/message'; +import PATH, { HREF } from 'constants/path'; +import useInputs from 'hooks/useInputs'; +import useListenManagerMainState from 'hooks/useListenManagerMainState'; +import useManagerMap from 'hooks/useManagerMap'; +import useManagerSpaces from 'hooks/useManagerSpaces'; +import { ManagerSpace, MapDrawing, MapElement, SpaceArea } from 'types/common'; +import { ErrorResponse } from 'types/response'; +import { createMapImageSvg } from 'utils/map'; +import * as Styled from './ManagerMapEditor.styles'; +import MapEditor from './units/MapEditor'; + +interface Params { + mapId?: string; +} + +interface Board { + name: string; + width: string; + height: string; +} + +const ManagerMapEditor = (): JSX.Element => { + const history = useHistory(); + const params = useParams(); + const mapId = params?.mapId; + const isEdit = !!mapId; + + const [mapElements, setMapElements] = useState([]); + const [{ name, width, height }, onChangeBoard, setBoard] = useInputs({ + name: '', + width: '800', + height: '600', + }); + + const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }, { enabled: isEdit }); + const spaces: ManagerSpace[] = useMemo(() => { + try { + return ( + managerSpaces.data?.data.spaces.map((space) => ({ + ...space, + area: JSON.parse(space.area) as SpaceArea, + })) ?? [] + ); + } catch (error) { + return []; + } + }, [managerSpaces.data?.data.spaces]); + + useManagerMap( + { mapId: Number(mapId) }, + { + enabled: isEdit, + onSuccess: ({ data }) => { + const { mapName, mapDrawing } = data; + + try { + const { mapElements, width, height } = JSON.parse(mapDrawing) as MapDrawing; + + setMapElements(mapElements); + setBoard({ + name: mapName ?? '', + width: `${width}`, + height: `${height}`, + }); + } catch (error) { + setMapElements([]); + } + }, + } + ); + + const createMap = useMutation(postMap, { + onSuccess: (response) => { + if (window.confirm(MESSAGE.MANAGER_MAP.CREATE_SUCCESS_CONFIRM)) { + const headers = response.headers as { location: string }; + const mapId = Number(headers.location.split('/').pop()); + + history.push(HREF.MANAGER_SPACE_EDIT(mapId)); + + return; + } + + history.push(PATH.MANAGER_MAIN); + }, + onError: (error: AxiosError) => { + alert(error?.response?.data.message ?? MESSAGE.MANAGER_MAP.UNEXPECTED_MAP_CREATE_ERROR); + }, + }); + + const updateMap = useMutation(putMap, { + onSuccess: () => { + alert(MESSAGE.MANAGER_MAP.UPDATE_SUCCESS); + }, + onError: (error: AxiosError) => { + alert(error?.response?.data.message ?? MESSAGE.MANAGER_MAP.UNEXPECTED_MAP_UPDATE_ERROR); + }, + }); + + const handleCancel = () => { + if (!window.confirm(MESSAGE.MANAGER_MAP.CANCEL_CONFIRM)) return; + + history.push(PATH.MANAGER_MAIN); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (createMap.isLoading || updateMap.isLoading) return; + + const mapDrawing = JSON.stringify({ width, height, mapElements }); + const mapImageSvg = createMapImageSvg({ + mapElements, + spaces, + width, + height, + }); + + if (isEdit) { + updateMap.mutate({ mapId: Number(mapId), mapName: name, mapDrawing, mapImageSvg }); + + return; + } + + createMap.mutate({ mapName: name, mapDrawing, mapImageSvg }); + }; + + useListenManagerMainState({ mapId: Number(mapId) }, { enabled: isEdit }); + + return ( + <> + +
+ + + + + + + + + + + + + + + + ); +}; + +export default ManagerMapEditor; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBindKeyPress.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBindKeyPress.ts new file mode 100644 index 000000000..23e8da785 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBindKeyPress.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from 'react'; + +const useBindKeyPress = (): { pressedKey: string } => { + const [pressedKey, setPressedKey] = useState(''); + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + setPressedKey(event.key); + }, []); + + const handleKeyUp = useCallback(() => { + setPressedKey(''); + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); + }; + }, [handleKeyDown, handleKeyUp]); + + return { pressedKey }; +}; + +export default useBindKeyPress; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardCoordinate.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardCoordinate.ts new file mode 100644 index 000000000..d2e3ba0af --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardCoordinate.ts @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { EDITOR } from 'constants/editor'; +import { Coordinate, EditorBoard } from 'types/common'; + +const useBoardCoordinate = ( + boardStatus: EditorBoard +): { + coordinate: Coordinate; + stickyCoordinate: Coordinate; + onMouseMove: (event: React.MouseEvent) => void; +} => { + const [coordinate, setCoordinate] = useState({ x: 0, y: 0 }); + const stickyCoordinate: Coordinate = { + x: Math.round(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + y: Math.round(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + }; + + const getSVGCoordinate = (event: React.MouseEvent) => { + const svg = (event.nativeEvent.target as SVGSVGElement)?.ownerSVGElement; + if (!svg) return { x: -1, y: -1 }; + + let point = svg.createSVGPoint(); + + point.x = event.nativeEvent.clientX; + point.y = event.nativeEvent.clientY; + point = point.matrixTransform(svg.getScreenCTM()?.inverse()); + + const x = (point.x - boardStatus.x) * (1 / boardStatus.scale); + const y = (point.y - boardStatus.y) * (1 / boardStatus.scale); + + return { x, y }; + }; + + const onMouseMove = (event: React.MouseEvent) => { + const { x, y } = getSVGCoordinate(event); + setCoordinate({ x, y }); + }; + + return { coordinate, stickyCoordinate, onMouseMove }; +}; + +export default useBoardCoordinate; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardEraserTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardEraserTool.ts new file mode 100644 index 000000000..508484c1a --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardEraserTool.ts @@ -0,0 +1,60 @@ +import { Dispatch, SetStateAction, useState } from 'react'; +import { MapElement } from 'types/common'; + +interface Props { + mapElements: [MapElement[], Dispatch>]; +} + +const useBoardEraserTool = ({ + mapElements: [, setMapElements], +}: Props): { + erasingMapElementIds: MapElement['id'][]; + isErasing: boolean; + eraseStart: () => void; + eraseEnd: () => void; + onMouseOverMapElement: (event: React.MouseEvent) => void; +} => { + const [erasingMapElementIds, setErasingMapElementIds] = useState([]); + const [isErasing, setErasing] = useState(false); + + const eraseEnd = () => { + setErasing(false); + setMapElements((prevMapElements) => + prevMapElements.filter(({ id }) => !erasingMapElementIds.includes(id)) + ); + setErasingMapElementIds([]); + }; + + const eraseStart = () => { + if (erasingMapElementIds.length > 0) { + eraseEnd(); + + return; + } + setErasing(true); + setErasingMapElementIds([]); + }; + + const selectErasingElement = (id: MapElement['id']) => { + if (!isErasing) return; + + setErasingMapElementIds((prevIds) => [...prevIds, id]); + }; + + const onMouseOverMapElement = (event: React.MouseEvent) => { + const target = event.target as SVGElement; + const [, mapElementId] = target.id.split('-'); + + selectErasingElement(Number(mapElementId)); + }; + + return { + erasingMapElementIds, + isErasing, + eraseStart, + eraseEnd, + onMouseOverMapElement, + }; +}; + +export default useBoardEraserTool; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts new file mode 100644 index 000000000..b271a45f5 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts @@ -0,0 +1,74 @@ +import { Dispatch, SetStateAction } from 'react'; +import { Color, Coordinate, DrawingStatus, MapElement } from 'types/common'; +import { MapElementType } from 'types/editor'; + +interface Props { + coordinate: Coordinate; + color: Color; + drawingStatus: [DrawingStatus, Dispatch>]; + mapElements: [MapElement[], Dispatch>]; +} + +const useBoardLineTool = ({ + coordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], +}: Props): { + drawLineStart: () => void; + drawLineEnd: () => void; +} => { + const nextMapElementId = Math.max(...mapElements.map(({ id }) => id), 1) + 1; + + const drawLineStart = () => { + if (drawingStatus.start) { + const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; + const endPoint = `${coordinate.x},${coordinate.y}`; + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Polyline, + stroke: color, + points: [startPoint, endPoint], + }, + ]); + + return; + } + + setDrawingStatus((prevState) => ({ + ...prevState, + start: coordinate, + })); + }; + + const drawLineEnd = () => { + if (!drawingStatus || !drawingStatus.start) return; + + const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; + const endPoint = `${coordinate.x},${coordinate.y}`; + + setDrawingStatus({}); + + if (startPoint === endPoint) return; + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Polyline, + stroke: color, + points: [startPoint, endPoint], + }, + ]); + }; + + return { + drawLineStart, + drawLineEnd, + }; +}; + +export default useBoardLineTool; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardMove.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardMove.ts new file mode 100644 index 000000000..d9937d7ae --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardMove.ts @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { EditorBoard } from 'types/common'; + +const useBoardMove = ( + statusState: [EditorBoard, React.Dispatch>], + isDraggable: boolean +): { + isDragging: boolean; + onDragStart: (event: React.MouseEvent) => void; + onDrag: (event: React.MouseEvent) => void; + onDragEnd: () => void; + onMouseOut: () => void; +} => { + const [status, setStatus] = statusState; + + const [isDragging, setDragging] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + const handleDragStart = (event: React.MouseEvent) => { + if (!isDraggable) return; + + setDragOffset({ + x: event.nativeEvent.offsetX - status.x, + y: event.nativeEvent.offsetY - status.y, + }); + + setDragging(true); + }; + + const handleDrag = (event: React.MouseEvent) => { + if (!isDraggable || !isDragging) return; + + const { offsetX, offsetY } = event.nativeEvent; + + setStatus((prevState) => ({ + ...prevState, + x: offsetX - dragOffset.x, + y: offsetY - dragOffset.y, + })); + }; + + const handleDragEnd = () => { + if (!isDraggable) return; + + setDragOffset({ x: 0, y: 0 }); + setDragging(false); + }; + + const handleMouseOut = () => { + setDragging(false); + }; + + return { + isDragging, + onDragStart: handleDragStart, + onDrag: handleDrag, + onDragEnd: handleDragEnd, + onMouseOut: handleMouseOut, + }; +}; + +export default useBoardMove; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts new file mode 100644 index 000000000..1640e760a --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts @@ -0,0 +1,105 @@ +import { Dispatch, SetStateAction } from 'react'; +import { Color, Coordinate, DrawingStatus, MapElement } from 'types/common'; +import { MapElementType } from 'types/editor'; + +interface Props { + coordinate: Coordinate; + color: Color; + drawingStatus: [DrawingStatus, Dispatch>]; + mapElements: [MapElement[], Dispatch>]; +} + +const useBoardRectTool = ({ + coordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], +}: Props): { + drawRectStart: () => void; + drawRectEnd: () => void; +} => { + const nextMapElementId = Math.max(...mapElements.map(({ id }) => id), 1) + 1; + + const drawRectStart = () => { + if (drawingStatus.start) { + const startPoint = `${drawingStatus.start.x},${drawingStatus.start.y}`; + const endPoint = `${coordinate.x},${coordinate.y}`; + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Rect, + stroke: color, + points: [startPoint, endPoint], + }, + ]); + + return; + } + + setDrawingStatus((prevState) => ({ + ...prevState, + start: coordinate, + })); + }; + + const drawRectEnd = () => { + if (!drawingStatus || !drawingStatus.start) return; + + const startPoint = { + x: drawingStatus.start.x, + y: drawingStatus.start.y, + }; + + const endPoint = { + x: coordinate.x, + y: coordinate.y, + }; + + const width = Math.abs(startPoint.x - endPoint.x); + const height = Math.abs(startPoint.y - endPoint.y); + + const startCoordinate = `${startPoint.x}, ${startPoint.y}`; + const endCoordinate = `${endPoint.x}, ${endPoint.y}`; + + setDrawingStatus({}); + + if (startCoordinate === endCoordinate) return; + + if (!width || !height) { + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Polyline, + stroke: color, + points: [`${startPoint.x},${startPoint.y}`, `${endPoint.x},${endPoint.y}`], + }, + ]); + + return; + } + + setMapElements((prevState) => [ + ...prevState, + { + id: nextMapElementId, + type: MapElementType.Rect, + stroke: color, + width, + height, + x: Math.min(startPoint.x, endPoint.x), + y: Math.min(startPoint.y, endPoint.y), + points: [startCoordinate, endCoordinate], + }, + ]); + }; + + return { + drawRectStart, + drawRectEnd, + }; +}; + +export default useBoardRectTool; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts new file mode 100644 index 000000000..9bd919197 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { Coordinate, GripPoint, MapElement } from 'types/common'; + +const useBoardSelect = (): { + gripPoints: GripPoint[]; + selectedMapElementId: number | null; + deselectMapElement: () => void; + onClickBoard: () => void; + onClickMapElement: (event: React.MouseEvent) => void; +} => { + const [gripPoints, setGripPoints] = useState([]); + const [selectedMapElementId, setSelectedMapElementId] = useState(null); + const nextGripPointId = Math.max(...gripPoints.map(({ id }) => id), 1) + 1; + + const selectLineElement = (target: SVGPolylineElement, id: MapElement['id']) => { + const points = Object.values(target?.points).map(({ x, y }) => ({ x, y })); + + const newGripPoints = points.map( + (point, index): GripPoint => ({ + id: nextGripPointId + index, + mapElementId: id, + x: point.x, + y: point.y, + }) + ); + + setSelectedMapElementId(id); + setGripPoints([...newGripPoints]); + }; + + const selectRectElement = (target: SVGRectElement, id: MapElement['id']) => { + const { x, y, width, height } = target; + + const pointX = x.baseVal.value; + const pointY = y.baseVal.value; + const widthValue = width.baseVal.value; + const heightValue = height.baseVal.value; + + const points = [ + { x: pointX, y: pointY }, + { x: pointX + widthValue, y: pointY }, + { x: pointX, y: pointY + heightValue }, + { x: pointX + widthValue, y: pointY + heightValue }, + ]; + + const newGripPoints = points.map( + (point, index): GripPoint => ({ + id: nextGripPointId + index, + mapElementId: id, + x: point.x, + y: point.y, + }) + ); + + setSelectedMapElementId(id); + setGripPoints([...newGripPoints]); + }; + + const deselectMapElement = () => { + setSelectedMapElementId(null); + setGripPoints([]); + }; + + const onClickBoard = () => { + deselectMapElement(); + }; + + const onClickMapElement = (event: React.MouseEvent) => { + const target = event.target as SVGElement; + const [mapElementType, mapElementId] = target.id.split('-'); + + if (mapElementType === 'polyline') { + selectLineElement(event.target as SVGPolylineElement, Number(mapElementId)); + + return; + } + + if (mapElementType === 'rect') { + selectRectElement(event.target as SVGRectElement, Number(mapElementId)); + } + }; + + return { + gripPoints, + selectedMapElementId, + deselectMapElement, + onClickBoard, + onClickMapElement, + }; +}; + +export default useBoardSelect; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardStatus.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardStatus.ts new file mode 100644 index 000000000..67c4c2c85 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardStatus.ts @@ -0,0 +1,32 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { EditorBoard } from 'types/common'; + +interface Props { + width: number; + height: number; +} + +const useBoardStatus = ({ + width = 800, + height = 600, +}: Props): [EditorBoard, Dispatch>] => { + const [boardStatus, setBoardStatus] = useState({ + scale: 1, + x: 0, + y: 0, + width, + height, + }); + + useEffect(() => { + setBoardStatus((prevStatus) => ({ + ...prevStatus, + width: Number(width), + height: Number(height), + })); + }, [width, height]); + + return [boardStatus, setBoardStatus]; +}; + +export default useBoardStatus; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardZoom.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardZoom.ts new file mode 100644 index 000000000..21ba6ff15 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardZoom.ts @@ -0,0 +1,51 @@ +import { EDITOR } from 'constants/editor'; +import { EditorBoard } from 'types/common'; + +const useBoardZoom = ( + statusState: [EditorBoard, React.Dispatch>] +): { + onWheel: (event: React.WheelEvent) => void; +} => { + const zoomBoard = ({ offsetX, offsetY, deltaY }: WheelEvent) => { + const [, setStatus] = statusState; + + setStatus((prevStatus) => { + const { scale, x, y, width, height } = prevStatus; + + const nextScale = scale - deltaY * EDITOR.SCALE_DELTA; + + if (nextScale <= EDITOR.MIN_SCALE || nextScale >= EDITOR.MAX_SCALE) { + return { + ...prevStatus, + scale: prevStatus.scale, + }; + } + + const cursorX = (offsetX - x) / (width * scale); + const cursorY = (offsetY - y) / (height * scale); + + const widthDiff = Math.abs(width * nextScale - width * scale) * cursorX; + const heightDiff = Math.abs(height * nextScale - height * scale) * cursorY; + + const nextX = nextScale > scale ? x - widthDiff : x + widthDiff; + const nextY = nextScale > scale ? y - heightDiff : y + heightDiff; + + return { + ...prevStatus, + x: nextX, + y: nextY, + scale: nextScale, + }; + }); + }; + + const handleWheel = (event: React.WheelEvent) => { + zoomBoard(event.nativeEvent); + }; + + return { + onWheel: handleWheel, + }; +}; + +export default useBoardZoom; diff --git a/frontend/src/pages/ManagerMapEditor/units/Board.styles.ts b/frontend/src/pages/ManagerMapEditor/units/Board.styles.ts new file mode 100644 index 000000000..26cd103d4 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/units/Board.styles.ts @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +interface RootSvgProps { + isDraggable: boolean; + isDragging: boolean; +} + +export const RootSvg = styled.svg` + cursor: ${({ isDraggable, isDragging }) => { + if (isDraggable) { + if (isDragging) return 'grabbing'; + else return 'grab'; + } + return 'default'; + }}; +`; diff --git a/frontend/src/pages/ManagerMapEditor/units/Board.tsx b/frontend/src/pages/ManagerMapEditor/units/Board.tsx new file mode 100644 index 000000000..59148f5e2 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/units/Board.tsx @@ -0,0 +1,93 @@ +import React, { PropsWithChildren, useLayoutEffect, useRef } from 'react'; +import PALETTE from 'constants/palette'; +import { EditorBoard } from 'types/common'; +import * as Styled from './Board.styles'; +import GridPattern from './GridPattern'; + +interface Props { + statusState: [EditorBoard, React.Dispatch>]; + isDraggable?: boolean; + isDragging?: boolean; + onClick?: (event: React.MouseEvent) => void; + onMouseMove?: (event: React.MouseEvent) => void; + onMouseDown?: (event: React.MouseEvent) => void; + onMouseUp?: (event: React.MouseEvent) => void; + onDragStart?: (event: React.MouseEvent) => void; + onDrag?: (event: React.MouseEvent) => void; + onDragEnd?: (event: React.MouseEvent) => void; + onMouseOut?: (event: React.MouseEvent) => void; + onWheel?: (event: React.WheelEvent) => void; +} + +const Board = ({ + statusState, + isDraggable = false, + isDragging = false, + onClick, + onMouseMove, + onMouseDown, + onMouseUp, + onDragStart, + onDrag, + onDragEnd, + onMouseOut, + onWheel, + children, +}: PropsWithChildren): JSX.Element => { + const rootSvgRef = useRef(null); + const [status, setStatus] = statusState; + + const handleMouseMove = (event: React.MouseEvent) => { + onMouseMove?.(event); + }; + + useLayoutEffect(() => { + const boardWidth = rootSvgRef.current?.clientWidth ?? 0; + const boardHeight = rootSvgRef.current?.clientHeight ?? 0; + + setStatus((prevStatus) => ({ + ...prevStatus, + x: (boardWidth - status.width) / 2, + y: (boardHeight - status.height) / 2, + })); + }, [setStatus, status.height, status.width]); + + return ( + + + + + + + + {children} + + + + ); +}; + +export default Board; diff --git a/frontend/src/pages/ManagerMapEditor/units/GridPattern.tsx b/frontend/src/pages/ManagerMapEditor/units/GridPattern.tsx new file mode 100644 index 000000000..a429b1101 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/units/GridPattern.tsx @@ -0,0 +1,51 @@ +import { EDITOR } from 'constants/editor'; +import PALETTE from 'constants/palette'; + +interface BoardSize { + width: number; + height: number; +} + +const GridPatternDefs = () => ( + + + + + + + + + +); + +const GridPattern = ({ width, height }: BoardSize): JSX.Element => ( + +); + +GridPattern.Defs = GridPatternDefs; + +export default GridPattern; diff --git a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.styles.ts b/frontend/src/pages/ManagerMapEditor/units/MapEditor.styles.ts similarity index 53% rename from frontend/src/pages/ManagerMapCreate/ManagerMapCreate.styles.ts rename to frontend/src/pages/ManagerMapEditor/units/MapEditor.styles.ts index 09922e05e..53e46f315 100644 --- a/frontend/src/pages/ManagerMapCreate/ManagerMapCreate.styles.ts +++ b/frontend/src/pages/ManagerMapEditor/units/MapEditor.styles.ts @@ -1,5 +1,4 @@ -import styled, { createGlobalStyle, css } from 'styled-components'; -import Button from 'components/Button/Button'; +import styled, { css } from 'styled-components'; import IconButton from 'components/IconButton/IconButton'; import Input from 'components/Input/Input'; @@ -7,95 +6,14 @@ interface ToolbarButtonProps { selected?: boolean; } -interface BoardContainerProps { - isDraggable?: boolean; - isDragging?: boolean; -} - -export const PageGlobalStyle = createGlobalStyle` - body { - overscroll-behavior: none; - } -`; - const primaryIconCSS = css` svg { fill: ${({ theme }) => theme.primary[400]}; } `; -export const Container = styled.div` - padding: 2rem 0; - display: flex; - flex-direction: column; - height: calc(100vh - 3rem); -`; - -export const ToolbarButton = styled(IconButton)` - background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; - border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; - border-radius: 0; - box-sizing: content-box; - - ${({ selected }) => selected && primaryIconCSS} -`; - -export const EditorHeader = styled.div``; - -export const Form = styled.form` - display: flex; - justify-content: space-between; - align-items: flex-end; - margin-bottom: 0.75rem; -`; - -export const HeaderContent = styled.div``; - -export const MapNameContainer = styled.div``; - -export const TempSaveContainer = styled.div` - margin-top: 0.25rem; -`; - -export const TempSaveMessage = styled.p` - display: inline; - color: ${({ theme }) => theme.gray[400]}; - font-size: 0.875rem; -`; - -export const TempSaveButton = styled(Button)` - padding: 0; - margin-left: 0.5rem; - font-size: 0.875rem; -`; - -export const MapNameInput = styled.input` - border: none; - border-radius: 0.125rem; - font-size: 1.5rem; - display: inline-block; - border: 2px solid transparent; - - &:hover { - border-color: ${({ theme }) => theme.primary[400]}; - } - - &:focus { - outline-color: ${({ theme }) => theme.primary[400]}; - } -`; - -export const MapName = styled.h3` - font-size: 1.5rem; - display: inline-block; -`; - -export const ButtonContainer = styled.div` - display: flex; - gap: 0.5rem; -`; - -export const EditorContent = styled.div` +export const Editor = styled.div` + position: relative; display: flex; flex: 1; border-top: 1px solid ${({ theme }) => theme.gray[400]}; @@ -114,20 +32,23 @@ export const Toolbar = styled.div` gap: 1rem; `; -export const Editor = styled.div` - flex: 1; +export const ToolbarButton = styled(IconButton)` + background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; + border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; + border-radius: 0; + box-sizing: content-box; + + ${({ selected }) => selected && primaryIconCSS} `; -export const BoardContainer = styled.svg` - outline: none; +export const ColorPicker = styled.div` + position: absolute; + left: 3.75rem; + top: 19rem; +`; - cursor: ${({ isDraggable, isDragging }) => { - if (isDraggable) { - if (isDragging) return 'grabbing'; - else return 'grab'; - } - return 'default'; - }}; +export const Board = styled.div` + flex: 1; `; export const InputWrapper = styled.div` @@ -137,10 +58,16 @@ export const InputWrapper = styled.div` margin: 0 0.25rem; `; -export const ColorPickerWrapper = styled.div` - position: absolute; - left: 4.25rem; - top: 22rem; +export const Label = styled.div` + color: ${({ theme }) => theme.gray[500]}; + text-align: center; + user-select: none; +`; + +export const LabelIcon = styled.div``; + +export const LabelText = styled.div` + font-size: 0.625rem; `; export const SizeInput = styled(Input)` @@ -162,18 +89,6 @@ export const SizeInput = styled(Input)` } `; -export const Label = styled.div` - color: ${({ theme }) => theme.gray[500]}; - text-align: center; - user-select: none; -`; - -export const LabelIcon = styled.div``; - -export const LabelText = styled.div` - font-size: 0.625rem; -`; - export const GripPoint = styled.circle` fill: ${({ theme }) => theme.white}; stroke: ${({ theme }) => theme.black[100]}; diff --git a/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx new file mode 100644 index 000000000..8896ee94e --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx @@ -0,0 +1,325 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ReactComponent as EraserIcon } from 'assets/svg/eraser.svg'; +import { ReactComponent as LineIcon } from 'assets/svg/line.svg'; +import { ReactComponent as MoveIcon } from 'assets/svg/move.svg'; +import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; +import { ReactComponent as SelectIcon } from 'assets/svg/select.svg'; +import ColorPicker from 'components/ColorPicker/ColorPicker'; +import ColorPickerIcon from 'components/ColorPicker/ColorPickerIcon'; +import { EDITOR, KEY } from 'constants/editor'; +import PALETTE from 'constants/palette'; +import useBoardCoordinate from 'pages/ManagerMapEditor/hooks/useBoardCoordinate'; +import { Color, DrawingStatus, ManagerSpace, MapElement } from 'types/common'; +import { MapElementType, Mode } from 'types/editor'; +import useBindKeyPress from '../hooks/useBindKeyPress'; +import useBoardEraserTool from '../hooks/useBoardEraserTool'; +import useBoardLineTool from '../hooks/useBoardLineTool'; +import useBoardMove from '../hooks/useBoardMove'; +import useBoardRectTool from '../hooks/useBoardRectTool'; +import useBoardSelect from '../hooks/useBoardSelect'; +import useBoardStatus from '../hooks/useBoardStatus'; +import useBoardZoom from '../hooks/useBoardZoom'; +import Board from './Board'; +import * as Styled from './MapEditor.styles'; + +const toolbarItems = [ + { + text: '선택', + mode: Mode.Select, + icon: , + }, + { + text: '이동', + mode: Mode.Move, + icon: , + }, + { + text: '선', + mode: Mode.Line, + icon: , + }, + { + text: '사각형', + mode: Mode.Rect, + icon: , + }, + { + text: '지우개', + mode: Mode.Eraser, + icon: , + }, +]; + +interface Props { + spaces: ManagerSpace[]; + mapElementsState: [MapElement[], React.Dispatch>]; + boardState: [ + { width: string; height: string }, + (event: React.ChangeEvent) => void + ]; +} + +const MapCreateEditor = ({ + spaces, + mapElementsState: [mapElements, setMapElements], + boardState: [{ width, height }, onChangeBoard], +}: Props): JSX.Element => { + const [mode, setMode] = useState(Mode.Select); + + const [color, setColor] = useState(PALETTE.BLACK[400]); + const [isColorPickerOpen, setColorPickerOpen] = useState(false); + + const [drawingStatus, setDrawingStatus] = useState({}); + + const { pressedKey } = useBindKeyPress(); + const isPressSpacebar = pressedKey === KEY.SPACE; + const isBoardDraggable = isPressSpacebar || mode === Mode.Move; + const isMapElementClickable = mode === Mode.Select && !isBoardDraggable; + const isMapElementEventAvailable = [Mode.Select, Mode.Eraser].includes(mode) && !isBoardDraggable; + + const [boardStatus, setBoardStatus] = useBoardStatus({ + width: Number(width), + height: Number(height), + }); + const { stickyCoordinate, onMouseMove } = useBoardCoordinate(boardStatus); + const { onWheel } = useBoardZoom([boardStatus, setBoardStatus]); + const { gripPoints, selectedMapElementId, deselectMapElement, onClickBoard, onClickMapElement } = + useBoardSelect(); + const { isDragging, onDragStart, onDrag, onDragEnd, onMouseOut } = useBoardMove( + [boardStatus, setBoardStatus], + isBoardDraggable + ); + const { drawLineStart, drawLineEnd } = useBoardLineTool({ + coordinate: stickyCoordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], + }); + const { drawRectStart, drawRectEnd } = useBoardRectTool({ + coordinate: stickyCoordinate, + color, + drawingStatus: [drawingStatus, setDrawingStatus], + mapElements: [mapElements, setMapElements], + }); + const { erasingMapElementIds, eraseStart, eraseEnd, onMouseOverMapElement } = useBoardEraserTool({ + mapElements: [mapElements, setMapElements], + }); + + const toggleColorPicker = () => setColorPickerOpen((prevState) => !prevState); + + const selectMode = (mode: Mode) => { + setDrawingStatus({}); + setMode(mode); + }; + + const handleMouseDown = () => { + if (isBoardDraggable || isDragging) return; + + if (mode === Mode.Line) drawLineStart(); + else if (mode === Mode.Rect) drawRectStart(); + else if (mode === Mode.Eraser) eraseStart(); + }; + + const handleMouseUp = () => { + if (isBoardDraggable || isDragging) return; + + if (mode === Mode.Line) drawLineEnd(); + else if (mode === Mode.Rect) drawRectEnd(); + else if (mode === Mode.Eraser) eraseEnd(); + }; + + const deleteMapElement = useCallback(() => { + if (!selectedMapElementId) return; + + setMapElements((prevMapElements) => + prevMapElements.filter(({ id }) => id !== selectedMapElementId) + ); + + deselectMapElement(); + }, [deselectMapElement, selectedMapElementId, setMapElements]); + + useEffect(() => { + if (mode !== Mode.Select) return; + + const isPressedDeleteKey = pressedKey === KEY.DELETE || pressedKey === KEY.BACK_SPACE; + + if (isPressedDeleteKey && selectedMapElementId) { + deleteMapElement(); + } + }, [deleteMapElement, mode, pressedKey, selectedMapElementId]); + + return ( + + + {toolbarItems.map((item) => ( + selectMode(item.mode)} + > + {item.icon} + + ))} + + + + + + + + + + {[Mode.Line, Mode.Rect].includes(mode) && ( + + )} + + {spaces.map(({ id, color, area, name }) => ( + + + + {name} + + + ))} + + {drawingStatus.start && mode === Mode.Line && ( + + )} + + {drawingStatus.start && mode === Mode.Rect && ( + + )} + + {mapElements.map((element) => { + if (element.type === MapElementType.Polyline) { + return ( + + ); + } + + if (element.type === MapElementType.Rect) { + return ( + + ); + } + + return null; + })} + + {mode === Mode.Select && + gripPoints.map(({ x, y }, index) => ( + + ))} + + + + + + W + 넓이 + + + + + + H + 높이 + + + + + + ); +}; + +export default MapCreateEditor; diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index b01d45169..b238a9651 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -1,4 +1,5 @@ import { DrawingAreaShape } from 'constants/editor'; +import { MapElementType } from './editor'; export type Color = string; @@ -15,7 +16,7 @@ export interface ScrollPosition { } export interface MapElement { id: number; - type: 'polyline' | 'rect'; + type: MapElementType; width?: number; height?: number; x?: number; diff --git a/frontend/src/types/editor.ts b/frontend/src/types/editor.ts index bdb503258..f6ea84c96 100644 --- a/frontend/src/types/editor.ts +++ b/frontend/src/types/editor.ts @@ -6,3 +6,8 @@ export enum Mode { Eraser = 'eraser', Decoration = 'decoration', } + +export enum MapElementType { + Polyline = 'polyline', + Rect = 'rect', +} diff --git a/frontend/src/utils/map.ts b/frontend/src/utils/map.ts new file mode 100644 index 000000000..77ece91f2 --- /dev/null +++ b/frontend/src/utils/map.ts @@ -0,0 +1,71 @@ +import { EDITOR } from 'constants/editor'; +import { ManagerSpace, MapElement } from 'types/common'; + +interface CreateMapImageSvgParams { + mapElements: MapElement[]; + spaces: ManagerSpace[]; + width: number | string; + height: number | string; +} + +export const createMapImageSvg = ({ + mapElements, + spaces, + width, + height, +}: CreateMapImageSvgParams): string => { + const mapImageSvg = ` + + ${spaces + ?.map( + ({ color, area }) => ` + + + ` + ) + .join('')} + + ${mapElements + .map((element) => + element.type === 'polyline' + ? ` + + ` + : ` + + ` + ) + .join('')} + + ` + .replace(/(\r\n\t|\n|\r\t|\s{1,})/gm, ' ') + .replace(/\s{2,}/g, ' '); + + return mapImageSvg; +}; From ed6fbf8defdb968660249d0d53ab568c5ca9d046 Mon Sep 17 00:00:00 2001 From: xrabcde Date: Tue, 14 Sep 2021 13:39:33 +0900 Subject: [PATCH 04/25] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=84=EC=9D=98=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.=20(#521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: setting 입력값들에 대한 예외 처리기능 추가 * fix: TimeUnitValidator에서 2시간 짜리 timeunit을 받지 못하도록 수정 * test: setting validation 관련 테스트 추가 * feat: 예약이 시작되는 시간과 닫히는 시간이 time unit단위와 맞는지를 검증하는 기능 추가 * feat: minimum, maximum time unit이 time unit 과 일치하는지 검증하는 기능 추가 * fix: 예약이 시작되는 시간과 닫히는 시간이 time unit에 맞는지 검증하는 로직 수정 * refactor: setting validation 로직 생성으로 파생되는 리팩터링 * fix: setting 관련해서 터지는 테스트수정 * fix: jacoco coverage 통과하도록 로직 및 테스트 수정 * refactor: settingTest public 접근 제어자 제거 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * chore: 젠킨스 촉발 * fix: 예약이 시작되는 시간 닫히는 시간이 정각 기준으로만 설정되고 time unit을 step으로 하여 나올 수 있는 시간만 가능하도록 검증하는 기능 * fix: jacoco coverage 통과하도록 수정 * refactor: available time 과 time unit이 매칭하는지 검증하는 메서드 명 수정 * refactor: check 메서드명 수정 * refactor: setting validation의 예외들이 inputFieldException을 상속받도록 리팩터링 * refactor: 불필요한 레거시 코드 제거 및 메서드명 변경 * fix: jacoco 커버리지 통과하도록 테스트 수정 Co-authored-by: sakjung --- .../woowacourse/zzimkkong/domain/Setting.java | 50 +++++++ .../woowacourse/zzimkkong/domain/Space.java | 8 +- .../zzimkkong/dto/TimeUnitValidator.java | 2 +- .../zzimkkong/dto/space/SettingsRequest.java | 2 +- .../exception/InputFieldException.java | 6 +- ...ossibleAvailableStartEndTimeException.java | 13 ++ ...nvalidMinimumMaximumTimeUnitException.java | 13 ++ .../NotEnoughAvailableTimeException.java | 13 ++ .../space/TimeUnitInconsistencyException.java | 13 ++ .../space/TimeUnitMismatchException.java | 13 ++ .../zzimkkong/service/ReservationService.java | 2 +- .../ManagerSpaceControllerTest.java | 10 +- .../zzimkkong/domain/SettingTest.java | 117 +++++++++++++++++ .../zzimkkong/domain/SpaceTest.java | 123 ++++++++++++++++-- .../zzimkkong/dto/SettingsRequestTest.java | 2 +- .../service/GuestReservationServiceTest.java | 16 +-- .../ManagerReservationServiceTest.java | 20 +-- 17 files changed, 372 insertions(+), 51 deletions(-) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/space/ImpossibleAvailableStartEndTimeException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/space/InvalidMinimumMaximumTimeUnitException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NotEnoughAvailableTimeException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitInconsistencyException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitMismatchException.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/domain/SettingTest.java diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java index b8ae18ec8..63ff3cef2 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Setting.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.domain; +import com.woowacourse.zzimkkong.exception.space.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,6 +8,7 @@ import javax.persistence.Column; import javax.persistence.Embeddable; import java.time.LocalTime; +import java.time.temporal.ChronoUnit; @Getter @Builder @@ -49,5 +51,53 @@ protected Setting( this.reservationMaximumTimeUnit = reservationMaximumTimeUnit; this.reservationEnable = reservationEnable; this.enabledDayOfWeek = enabledDayOfWeek; + + validateSetting(); + } + + private void validateSetting() { + if (availableStartTime.equals(availableEndTime) || availableStartTime.isAfter(availableEndTime)) { + throw new ImpossibleAvailableStartEndTimeException(); + } + + if (isNoneMatchingAvailableTimeAndTimeUnit()) { + throw new TimeUnitMismatchException(); + } + + if (reservationMaximumTimeUnit < reservationMinimumTimeUnit) { + throw new InvalidMinimumMaximumTimeUnitException(); + } + + if (isNotConsistentTimeUnit()) { + throw new TimeUnitInconsistencyException(); + } + + int duration = (int) ChronoUnit.MINUTES.between(availableStartTime, availableEndTime); + if (duration < reservationMaximumTimeUnit) { + throw new NotEnoughAvailableTimeException(); + } + } + + private boolean isNoneMatchingAvailableTimeAndTimeUnit() { + return isNotDivisibleByTimeUnit(availableStartTime.getMinute()) || isNotDivisibleByTimeUnit(availableEndTime.getMinute()); + } + + public boolean isNotDivisibleByTimeUnit(final int minute) { + return minute % this.reservationTimeUnit != 0; + } + + private boolean isNotConsistentTimeUnit() { + return !(isMinimumTimeUnitConsistentWithTimeUnit() && isMaximumTimeUnitConsistentWithTimeUnit()); + } + + private boolean isMinimumTimeUnitConsistentWithTimeUnit() { + int minimumTimeUnitQuotient = reservationMinimumTimeUnit / reservationTimeUnit; + int minimumTimeUnitRemainder = reservationMinimumTimeUnit % reservationTimeUnit; + return minimumTimeUnitRemainder == 0 && 1 <= minimumTimeUnitQuotient; + } + + private boolean isMaximumTimeUnitConsistentWithTimeUnit() { + int maximumTimeUnitRemainder = reservationMaximumTimeUnit % reservationTimeUnit; + return maximumTimeUnitRemainder == 0; } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java index 45bfc79a0..c61989ff8 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Space.java @@ -92,18 +92,14 @@ public boolean isNotBetweenAvailableTime(final LocalDateTime startDateTime, Loca return !(isEqualOrAfterStartTime && isEqualOrBeforeEndTime); } - public boolean isIncorrectTimeUnit(final int minute) { - return minute != 0 && isNotDivideBy(minute); + public boolean isNotDivisibleByTimeUnit(final int minute) { + return setting.isNotDivisibleByTimeUnit(minute); } public boolean isIncorrectMinimumMaximumTimeUnit(final int durationMinutes) { return durationMinutes < getReservationMinimumTimeUnit() || durationMinutes > getReservationMaximumTimeUnit(); } - public boolean isNotDivideBy(final int minute) { - return minute % getReservationTimeUnit() != 0; - } - public boolean isUnableToReserve() { return !getReservationEnable(); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java index 16b34b1ff..9486b38a1 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/TimeUnitValidator.java @@ -5,7 +5,7 @@ import java.util.List; public class TimeUnitValidator implements ConstraintValidator { - private static final List TIME_UNITS = List.of(5, 10, 30, 60, 120); + private static final List TIME_UNITS = List.of(5, 10, 30, 60); @Override public boolean isValid(Integer value, ConstraintValidatorContext context) { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java index 249ed7d99..1688103b2 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java @@ -26,7 +26,7 @@ public class SettingsRequest { private Integer reservationMinimumTimeUnit = 10; - private Integer reservationMaximumTimeUnit = 1440; + private Integer reservationMaximumTimeUnit = 120; private Boolean reservationEnable = true; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java index 1c6c2c691..8df96b44b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/InputFieldException.java @@ -10,6 +10,8 @@ public class InputFieldException extends ZzimkkongException { protected static final String RESERVATION_PASSWORD = "password"; protected static final String START_DATE_TIME = "startDateTime"; protected static final String END_DATE_TIME = "endDateTime"; + protected static final String AVAILABLE_START_END_TIME = "availableStartEndTime"; + protected static final String MINIMUM_MAXIMUM_TIME_UNIT = "minimumMaximumTimeUnit"; private final String field; @@ -17,8 +19,4 @@ public InputFieldException(final String message, final HttpStatus status, final super(message, status); this.field = field; } - - public String getField() { - return field; - } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/ImpossibleAvailableStartEndTimeException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/ImpossibleAvailableStartEndTimeException.java new file mode 100644 index 000000000..4158b56f2 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/ImpossibleAvailableStartEndTimeException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class ImpossibleAvailableStartEndTimeException extends InputFieldException { + private static final String MESSAGE = "예약이 닫힐 시간은 예약이 열릴 시간보다 이전일 수 없습니다."; + + public ImpossibleAvailableStartEndTimeException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, AVAILABLE_START_END_TIME); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/InvalidMinimumMaximumTimeUnitException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/InvalidMinimumMaximumTimeUnitException.java new file mode 100644 index 000000000..b4eb43c43 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/InvalidMinimumMaximumTimeUnitException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class InvalidMinimumMaximumTimeUnitException extends InputFieldException { + private static final String MESSAGE = "최대 예약 가능시간은 최소 예약 가능시간보다 작을 수 없습니다"; + + public InvalidMinimumMaximumTimeUnitException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, MINIMUM_MAXIMUM_TIME_UNIT); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NotEnoughAvailableTimeException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NotEnoughAvailableTimeException.java new file mode 100644 index 000000000..5670b1d5c --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NotEnoughAvailableTimeException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class NotEnoughAvailableTimeException extends InputFieldException { + private static final String MESSAGE = "예약 가능한 시간의 범위가 최대 예약 가능 시간보다 작을 수 없습니다."; + + public NotEnoughAvailableTimeException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, AVAILABLE_START_END_TIME); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitInconsistencyException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitInconsistencyException.java new file mode 100644 index 000000000..cecfabc81 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitInconsistencyException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class TimeUnitInconsistencyException extends InputFieldException { + private static final String MESSAGE = "최소, 최대 예약 가능 시간의 단위는 예약 시간 단위와 일치해야합니다"; + + public TimeUnitInconsistencyException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, MINIMUM_MAXIMUM_TIME_UNIT); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitMismatchException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitMismatchException.java new file mode 100644 index 000000000..d80111de2 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/TimeUnitMismatchException.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.exception.space; + +import com.woowacourse.zzimkkong.exception.InputFieldException; +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class TimeUnitMismatchException extends InputFieldException { + private static final String MESSAGE = "예약이 열릴 시간과 닫힐 시간은 예약 시간 단위와 맞아야 합니다"; + + public TimeUnitMismatchException() { + super(MESSAGE, HttpStatus.BAD_REQUEST, AVAILABLE_START_END_TIME); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java index edd47144f..c5f92919f 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/ReservationService.java @@ -227,7 +227,7 @@ private void validateAvailability( private void validateSpaceSetting(Space space, LocalDateTime startDateTime, LocalDateTime endDateTime) { int durationMinutes = (int) ChronoUnit.MINUTES.between(startDateTime, endDateTime); - if (space.isIncorrectTimeUnit(startDateTime.getMinute()) | space.isNotDivideBy(durationMinutes)) { + if (space.isNotDivisibleByTimeUnit(startDateTime.getMinute()) || space.isNotDivisibleByTimeUnit(durationMinutes)) { throw new InvalidTimeUnitException(); } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java index 3a17fd902..cadce586c 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java @@ -92,7 +92,7 @@ void save() { LocalTime.of(20, 0), 30, 60, - 100, + 120, true, "monday, tuesday, wednesday, thursday, friday, saturday, sunday" ); @@ -141,7 +141,7 @@ void save_default() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek("monday, tuesday, wednesday, thursday, friday, saturday, sunday") .build(); @@ -207,9 +207,9 @@ void update() { SettingsRequest settingsRequest = new SettingsRequest( LocalTime.of(10, 0), LocalTime.of(22, 0), - 40, - 80, - 130, + 30, + 60, + 120, false, "monday, tuesday, wednesday, thursday, friday, saturday, sunday" ); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/SettingTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SettingTest.java new file mode 100644 index 000000000..9d47b6ce1 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SettingTest.java @@ -0,0 +1,117 @@ +package com.woowacourse.zzimkkong.domain; + +import com.woowacourse.zzimkkong.exception.space.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalTime; + +import static com.woowacourse.zzimkkong.Constants.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class SettingTest { + @Test + @DisplayName("setting의 입력값이 모두 올바르면 setting을 생성한다") + void name() { + assertDoesNotThrow(() -> Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build()); + } + + @ParameterizedTest + @CsvSource(value = {"10,9", "10,10"}) + @DisplayName("setting 생성 시 예약이 열릴 시간이 예약 닫힐 시간 이후거나 같으면 예외를 던진다") + void invalidAvailableStartEndTime(int availableStartTimeHour, int availableEndTimeHour) { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(LocalTime.of(availableStartTimeHour, 0)) + .availableEndTime(LocalTime.of(availableEndTimeHour, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(ImpossibleAvailableStartEndTimeException.class); + } + + @Test + @DisplayName("setting 생성 시 최대 예약 가능 시간이 최소 예약 가능시간 보다 작으면 예외를 던진다") + void invalidMinimumMaximumTimeUnit() { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(20) + .reservationMaximumTimeUnit(10) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(InvalidMinimumMaximumTimeUnitException.class); + } + + @Test + @DisplayName("setting 생성 시 예약이 가능한 시간 범위가 최대 예약 가능 시간 보다 작으면 예외를 던진다") + void notEnoughTimeAvailable() { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(LocalTime.of(10, 0)) + .availableEndTime(LocalTime.of(11, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(70) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(NotEnoughAvailableTimeException.class); + } + + @ParameterizedTest + @CsvSource(value = {"30,50,10", "0,0,10", "10,15,5", "0,5,5", "0,30,30", "0,0,60"}) + @DisplayName("setting 생성 시 예약이 시작되는 시간과 닫히는 시간이 time unit단위와 맞으면 예외를 던지지 않는다") + void timeUnitMismatch_ok(int startMinute, int endMinute, int timeUnit) { + assertDoesNotThrow(() -> Setting.builder() + .availableStartTime(LocalTime.of(10, startMinute)) + .availableEndTime(LocalTime.of(20, endMinute)) + .reservationTimeUnit(timeUnit) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build()); + } + + @ParameterizedTest + @CsvSource(value = {"22,27,5", "0,15,10", "15,0,10", "10,40,30", "5,5,60"}) + @DisplayName("setting 생성 시 예약이 시작되는 시간과 닫히는 시간이 time unit단위와 맞지 않으면 예외를 던진다") + void timeUnitMismatch_fail(int startMinute, int endMinute, int timeUnit) { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(LocalTime.of(10, startMinute)) + .availableEndTime(LocalTime.of(20, endMinute)) + .reservationTimeUnit(timeUnit) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(TimeUnitMismatchException.class); + } + + @ParameterizedTest + @CsvSource(value = {"0,5", "5,10", "9,20", "10,25", "15,30", "25,45"}) + @DisplayName("setting 생성 시 최소,최대 예약 가능 시간의 단위가 예약 시간 단위와 일치하지 않으면 예외를 던진다") + void timeUnitInconsistency_fail(int minimumMinute, int maximumMinute) { + final Setting.SettingBuilder settingBuilder = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(10) + .reservationMinimumTimeUnit(minimumMinute) + .reservationMaximumTimeUnit(maximumMinute) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK); + assertThatThrownBy(settingBuilder::build).isInstanceOf(TimeUnitInconsistencyException.class); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java index b9dba4275..fd2dfdbe5 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/SpaceTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; @@ -22,6 +23,11 @@ void update() { Setting setting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space space = Space.builder() .name("와우") @@ -35,7 +41,11 @@ void update() { Setting updateSetting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) - .reservationEnable(true) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(false) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space updateSpace = Space.builder() .name("우와") @@ -61,17 +71,23 @@ void hasSameId() { assertThat(space.hasSameId(space.getId() + 1)).isFalse(); } - @Test + @ParameterizedTest + @CsvSource(value = {"10,12", "17,18"}) @DisplayName("예약하려는 시간이 공간의 예약 가능한 시간 내에 있다면 false를 반환한다") - void isNotBetweenAvailableTime() { + void isNotBetweenAvailableTime(int startHour, int endHour) { Setting availableTimeSetting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); - LocalDateTime startDateTime = THE_DAY_AFTER_TOMORROW.atTime(10, 0); - LocalDateTime endDateTime = THE_DAY_AFTER_TOMORROW.atTime(18, 0); + LocalDateTime startDateTime = THE_DAY_AFTER_TOMORROW.atTime(startHour, 0); + LocalDateTime endDateTime = THE_DAY_AFTER_TOMORROW.atTime(endHour, 0); boolean actual = availableTimeSpace.isNotBetweenAvailableTime(startDateTime, endDateTime); assertThat(actual).isFalse(); @@ -83,6 +99,11 @@ void isNotBetweenAvailableTimeFail() { Setting availableTimeSetting = Setting.builder() .availableStartTime(LocalTime.of(10, 0)) .availableEndTime(LocalTime.of(18, 0)) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); @@ -98,11 +119,17 @@ void isNotBetweenAvailableTimeFail() { @DisplayName("예약 시작 시간의 단위가 타당하면 false를 반환한다.") void isCorrectTimeUnit(int minute) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); - boolean actual = availableTimeSpace.isIncorrectTimeUnit(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isFalse(); } @@ -112,11 +139,17 @@ void isCorrectTimeUnit(int minute) { @DisplayName("예약 시작 시간의 단위가 타당하지 않다면 true를 반환한다.") void isCorrectTimeUnitFail(int minute) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); - boolean actual = availableTimeSpace.isIncorrectTimeUnit(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isTrue(); } @@ -126,8 +159,13 @@ void isCorrectTimeUnitFail(int minute) { @DisplayName("예약 시간의 단위가 최소최대 예약시간단위 내에 있다면 false를 반환한다.") void isCorrectMinimumMaximumTimeUnit(int durationMinutes) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) .reservationMinimumTimeUnit(10) .reservationMaximumTimeUnit(120) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); @@ -141,8 +179,13 @@ void isCorrectMinimumMaximumTimeUnit(int durationMinutes) { @DisplayName("예약 시간의 단위가 최소시간단위보다 작거나 최대시간단위보다 크다면 true를 반환한다.") void isCorrectMinimumMaximumTimeUnitFail(int durationMinutes) { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) .reservationMinimumTimeUnit(10) .reservationMaximumTimeUnit(120) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); @@ -155,12 +198,18 @@ void isCorrectMinimumMaximumTimeUnitFail(int durationMinutes) { @DisplayName("예약 시간의 단위가 공간의 timeUnit으로 나누어떨어지면 false를 반환한다.") void isNotDivideBy() { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); int minute = 100; - boolean actual = availableTimeSpace.isNotDivideBy(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isFalse(); } @@ -169,12 +218,18 @@ void isNotDivideBy() { @DisplayName("예약 시간의 단위가 공간의 timeUnit으로 나누어떨어지지 않으면 true를 반환한다.") void isNotDivideByFail() { Setting availableTimeSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) .reservationTimeUnit(10) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) .build(); Space availableTimeSpace = Space.builder().setting(availableTimeSetting).build(); int minute = 12; - boolean actual = availableTimeSpace.isNotDivideBy(minute); + boolean actual = availableTimeSpace.isNotDivisibleByTimeUnit(minute); assertThat(actual).isTrue(); } @@ -182,7 +237,15 @@ void isNotDivideByFail() { @Test @DisplayName("예약이 가능한 공간이면 false를 반환한다") void isUnableToReserve() { - Setting reservationEnableSetting = Setting.builder().reservationEnable(true).build(); + Setting reservationEnableSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(true) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build(); Space reservationEnableSpace = Space.builder().setting(reservationEnableSetting).build(); assertThat(reservationEnableSpace.isUnableToReserve()).isFalse(); @@ -191,7 +254,15 @@ void isUnableToReserve() { @Test @DisplayName("예약이 불가능한 공간이면 true를 반환한다") void isUnableToReserveFail() { - Setting reservationUnableSetting = Setting.builder().reservationEnable(false).build(); + Setting reservationUnableSetting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(false) + .enabledDayOfWeek(FE_ENABLED_DAY_OF_WEEK) + .build(); Space reservationUnableSpace = Space.builder().setting(reservationUnableSetting).build(); assertThat(reservationUnableSpace.isUnableToReserve()).isTrue(); @@ -201,7 +272,15 @@ void isUnableToReserveFail() { @EnumSource(value = DayOfWeek.class, names = {"MONDAY", "WEDNESDAY"}) @DisplayName("해당 요일에 예약이 가능하면 false를 반환한다") void isClosedOn(DayOfWeek dayOfWeek) { - Setting setting = Setting.builder().enabledDayOfWeek("monday, wednesday").build(); + Setting setting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek("monday, wednesday") + .build(); Space space = Space.builder().setting(setting).build(); assertThat(space.isClosedOn(dayOfWeek)).isFalse(); @@ -211,7 +290,15 @@ void isClosedOn(DayOfWeek dayOfWeek) { @EnumSource(value = DayOfWeek.class, names = {"TUESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"}) @DisplayName("해당 요일에 예약이 불가능하면 true를 반환한다") void isClosedOnFail(DayOfWeek dayOfWeek) { - Setting setting = Setting.builder().enabledDayOfWeek("monday, wednesday").build(); + Setting setting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek("monday, wednesday") + .build(); Space space = Space.builder().setting(setting).build(); assertThat(space.isClosedOn(dayOfWeek)).isTrue(); @@ -221,7 +308,15 @@ void isClosedOnFail(DayOfWeek dayOfWeek) { @EnumSource(value = DayOfWeek.class) @DisplayName("예약 가능한 요일이 null이면 모든 요일에 대해서 true를 반환한다") void isClosedOn_nullEnabledDayOfWeek(DayOfWeek dayOfWeek) { - Setting setting = Setting.builder().enabledDayOfWeek(null).build(); + Setting setting = Setting.builder() + .availableStartTime(FE_AVAILABLE_START_TIME) + .availableEndTime(FE_AVAILABLE_END_TIME) + .reservationTimeUnit(FE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(FE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(FE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(FE_RESERVATION_ENABLE) + .enabledDayOfWeek(null) + .build(); Space space = Space.builder().setting(setting).build(); assertThat(space.isClosedOn(dayOfWeek)).isTrue(); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java index eb02290ee..17e4563ac 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java @@ -35,7 +35,7 @@ void invalidTimeUnit(int timeUnit) { @ParameterizedTest @NullSource - @ValueSource(ints = {5, 10, 30, 60, 120}) + @ValueSource(ints = {5, 10, 30, 60}) @DisplayName("공간의 예약 설정에 단위 시간이 올바르게 들어온다.") void validTimeUnit(Integer timeUnit) { SettingsRequest settingsRequest = new SettingsRequest( diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java index ecde41521..3a199f646 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/GuestReservationServiceTest.java @@ -365,7 +365,7 @@ void saveReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -405,7 +405,7 @@ void saveIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); @@ -474,9 +474,9 @@ void saveSameThresholdTime(int duration) { } @ParameterizedTest - @ValueSource(ints = {1, 2, 3, 4, 5, 9, 15, 29}) + @CsvSource({"1,61","10,55","5,65","20,89"}) @DisplayName("예약 생성/수정 요청 시, space setting의 reservationTimeUnit이 일치하지 않으면 예외가 발생한다.") - void saveReservationTimeUnitException(int minute) { + void saveReservationTimeUnitException(int additionalStartMinute, int additionalEndMinute) { //given given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); @@ -486,8 +486,8 @@ void saveReservationTimeUnitException(int minute) { //when ReservationCreateUpdateWithPasswordRequest reservationCreateUpdateWithPasswordRequest = new ReservationCreateUpdateWithPasswordRequest( - theDayAfterTomorrowTen.plusMinutes(minute), - theDayAfterTomorrowTen.plusMinutes(minute).plusMinutes(60), + theDayAfterTomorrowTen.plusMinutes(additionalStartMinute), + theDayAfterTomorrowTen.plusMinutes(additionalEndMinute), RESERVATION_PW, USER_NAME, DESCRIPTION); @@ -991,7 +991,7 @@ void updateReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -1035,7 +1035,7 @@ void updateIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java index ce15073b6..242a1c582 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/ManagerReservationServiceTest.java @@ -409,7 +409,7 @@ void saveReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -451,7 +451,7 @@ void saveIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); @@ -522,9 +522,9 @@ void saveSameThresholdTime(int duration) { } @ParameterizedTest - @ValueSource(ints = {1, 2, 3, 4, 5, 9, 15, 29}) + @CsvSource({"1,61","10,55","5,65","20,89"}) @DisplayName("예약 생성/수정 요청 시, space setting의 reservationTimeUnit이 일치하지 않으면 예외가 발생한다.") - void saveReservationTimeUnitException(int minute) { + void saveReservationTimeUnitException(int additionalStartMinute, int additionalEndMinute) { //given given(maps.findById(anyLong())) .willReturn(Optional.of(luther)); @@ -534,14 +534,14 @@ void saveReservationTimeUnitException(int minute) { //when ReservationCreateUpdateWithPasswordRequest reservationCreateUpdateWithPasswordRequest = new ReservationCreateUpdateWithPasswordRequest( - theDayAfterTomorrowTen.plusMinutes(minute), - theDayAfterTomorrowTen.plusMinutes(minute).plusMinutes(60), + theDayAfterTomorrowTen.plusMinutes(additionalStartMinute), + theDayAfterTomorrowTen.plusMinutes(additionalEndMinute), RESERVATION_PW, USER_NAME, DESCRIPTION); ReservationCreateUpdateRequest reservationCreateUpdateRequest = new ReservationCreateUpdateRequest( - theDayAfterTomorrowTen.plusMinutes(minute), - theDayAfterTomorrowTen.plusMinutes(minute).plusMinutes(60), + theDayAfterTomorrowTen.plusMinutes(additionalStartMinute), + theDayAfterTomorrowTen.plusMinutes(additionalEndMinute), USER_NAME, DESCRIPTION); @@ -1136,7 +1136,7 @@ void updateReservationUnable() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(false) .enabledDayOfWeek(null) .build(); @@ -1181,7 +1181,7 @@ void updateIllegalDayOfWeek() { .availableEndTime(LocalTime.of(18, 0)) .reservationTimeUnit(10) .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) + .reservationMaximumTimeUnit(120) .reservationEnable(true) .enabledDayOfWeek(THE_DAY_AFTER_TOMORROW.plusDays(1L).getDayOfWeek().name()) .build(); From e5c65e4e737229caa327cef0eb2acff3ebc0d24d Mon Sep 17 00:00:00 2001 From: Yeonwoo Cho Date: Tue, 14 Sep 2021 14:24:16 +0900 Subject: [PATCH 05/25] =?UTF-8?q?feat:=20oauth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20(#?= =?UTF-8?q?527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: OAuth 제공사 클래스 생성 * feat: OAuth 요청에 대한 인터페이스 구현 * feat: OAuth 회원가입 초안 코드 작성 * refactor: OAuth 회원가입 요청 api 수정 * style: OAuth 관련 카멜케이스 준수하도록 수정 * refactor: property 상위 클래스로 이동 * feat: google oauth 구현체 구현 * feat: OAuth 제공사 클래스 생성 * feat: OAuth 요청에 대한 인터페이스 구현 * feat: OAuth 회원가입 초안 코드 작성 * refactor: OAuth 회원가입 요청 api 수정 * style: OAuth 관련 카멜케이스 준수하도록 수정 * chore: WebFlux 의존성 추가 * chore: Mock Web Server 의존성 추가 * refactor: 깃허브 OAuth 및 Open API 연동 기능 추가 * feat: OAuth 로그인 및 회원가입 초안 작성 test는 미완입니다. * refactor: OAuthMemberSaveRequest에 password 삭제 * style: 통일할 부분 주석 처리 * test: webClient 추가 * refactor: GithubRequester 생성자 시그니쳐 변경 * refactor: google 로그인 구현체 정리 * refactor: google 사용하지 않는 응답 삭제 * test: GoogleRequester 테스트 작성 * test: OAuthHandler 테스트 작성 * refactor: response 관련 예외처리, dto 변경 * refactor: GoogleUserResponse의 import문 오류 수정 * feat: OAuthProvider String 값으로부터 대소문자를 가리지 않고 provider를 찾아오는 기능 추가 * fix: GithubRequester config 경로 오탈 수정 * test: OAuth 로그인 Service Test(Paramiterized) 테스트 작성 * test: google oauth 서비스 테스트 작성 * test: google oauth authController 테스트 작성 * refactor: response, request jackson 바인딩 안되는 요소들 수정, provider 찾는 메서드 추가 * test: Google oauth memberController 테스트 작성 * test: OAuth를 통한 회원가입 테스트 작성 * test: OAuthMemberSaveRequest 테스트 작성 * feat: OAuth 로그인시 OAuth 제공사가 일치하는지 검증 * test: OAuth를 통한 로그인 테스트 작성 * fix: dto에 대해 oauthProvider 카멜케이스 컨벤션 변경 * test: OauthProvider service 테스트 통합 * style: todo 리스트 작성 * chore: 서브 모듈 pull * chore: 서브 모듈에 oauth 설정파일 추가 * chore: 서브모듈 최신화 * chore: 깃허브 요청 url 서브모듈 config에 추가 * refactor: AuthControllerTest 일부 테스트명, DisplayName 수정 * refactor: 코드 총정리 * refactor: api url members -> managers * test: 부족한 테스트 추가 * refactor: managers -> members * refactor: members/join -> members * refactor: JwtUtils 토큰 검증 로직 최적화 - jwtParser의 setSigningKey() 메소드는 최초 생성시 1회면 충분하기에 최적화합니다. * refactor: oauth uri guests -> members * refactor: 다른 oauth 로그인 시 원래 제공자 전달 * refactor: NoSuchException 404 반환하도록 변경 * refactor: Github 개발 환경에 따른 설정값 프로파일화 * refactor: OauthHandler 불필요 어노테이션 삭제 * refactor: requester 구현체 통일 * refactor: 사소한 코드 리팩터링 * refactor: GoogleRequesterTest response 수정 * refactor: dataLoader 삭제 * refactor: uri 통합 * refactor: members -> managers uri 변경 Co-authored-by: Kimun Kim --- backend/build.gradle | 24 +- backend/src/docs/asciidoc/member.adoc | 36 +++ .../com/woowacourse/zzimkkong/DataLoader.java | 226 ------------------ .../config/AuthenticationPrincipalConfig.java | 22 +- .../zzimkkong/config/OauthConfig.java | 14 ++ .../zzimkkong/config/OauthGithubConfig.java | 42 ++++ .../zzimkkong/controller/AuthController.java | 16 +- .../controller/ManagerSpaceController.java | 4 +- .../controller/MemberController.java | 27 ++- .../woowacourse/zzimkkong/domain/Member.java | 17 +- .../zzimkkong/domain/OauthProvider.java | 20 ++ .../domain/oauth/GithubUserInfo.java | 30 +++ .../domain/oauth/GoogleUserInfo.java | 27 +++ .../zzimkkong/domain/oauth/OauthUserInfo.java | 5 + .../member/oauth/OauthMemberSaveRequest.java | 34 +++ .../dto/member/oauth/OauthReadyResponse.java | 21 ++ .../OauthProviderMismatchException.java | 12 + .../UnsupportedOauthProviderException.java | 12 + ...sponseToGetGithubAccessTokenException.java | 12 + .../oauth/NoPublicEmailOnGithubException.java | 12 + ...ToGetTokenResponseFromGithubException.java | 12 + ...ToGetTokenResponseFromGoogleException.java | 12 + .../exception/map/NoSuchMapException.java | 2 +- .../member/NoSuchMemberException.java | 2 +- .../preset/NoSuchPresetException.java | 2 +- .../NoSuchReservationException.java | 2 +- .../space/NoSuchDayOfWeekException.java | 2 +- .../exception/space/NoSuchSpaceException.java | 2 +- .../zzimkkong/infrastructure/JwtUtils.java | 2 +- .../infrastructure/oauth/GithubRequester.java | 91 +++++++ .../infrastructure/oauth/GoogleRequester.java | 101 ++++++++ .../oauth/OauthAPIRequester.java | 10 + .../infrastructure/oauth/OauthHandler.java | 30 +++ .../oauth/StringToOauthProviderConverter.java | 13 + .../zzimkkong/service/AuthService.java | 35 ++- .../zzimkkong/service/MemberService.java | 32 ++- backend/src/main/resources/config | 2 +- .../StringToOauthProviderConverterTest.java | 24 ++ .../zzimkkong/controller/AcceptanceTest.java | 8 + .../controller/AuthControllerTest.java | 73 +++++- .../controller/MemberControllerTest.java | 105 +++++++- .../domain/oauth/GithubUserInfoTest.java | 40 ++++ .../dto/OauthMemberSaveRequestTest.java | 68 ++++++ .../oauth/GithubRequesterTest.java | 97 ++++++++ .../oauth/GoogleRequesterTest.java | 92 +++++++ .../oauth/OauthHandlerTest.java | 66 +++++ .../zzimkkong/service/AuthServiceTest.java | 85 +++++++ .../zzimkkong/service/MemberServiceTest.java | 99 ++++++++ 48 files changed, 1448 insertions(+), 274 deletions(-) delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/OauthConfig.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/OauthGithubConfig.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/domain/OauthProvider.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfo.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GoogleUserInfo.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/OauthUserInfo.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthMemberSaveRequest.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/UnsupportedOauthProviderException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetGithubAccessTokenException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/NoPublicEmailOnGithubException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGithubException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGoogleException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthAPIRequester.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/StringToOauthProviderConverter.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/config/StringToOauthProviderConverterTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfoTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/dto/OauthMemberSaveRequestTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java diff --git a/backend/build.gradle b/backend/build.gradle index cf6441d2e..0c4869ff5 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -20,46 +20,52 @@ ext { } dependencies { - //spring + // Spring implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - implementation 'org.projectlombok:lombok:1.18.18' // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' - //database + // Database runtimeOnly 'mysql:mysql-connector-java' runtimeOnly 'com.h2database:h2' - //flyway + + // Flyway implementation 'org.flywaydb:flyway-core:6.4.2' + // Test testImplementation 'io.rest-assured:rest-assured:3.3.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' - //restdocs + // Restdocs asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' - //jwt + // Jwt implementation 'io.jsonwebtoken:jjwt:0.9.1' - //svg + // SvgToPng implementation 'org.apache.xmlgraphics:batik-all:1.12' implementation 'org.apache.xmlgraphics:xmlgraphics-commons:2.4' implementation 'xml-apis:xml-apis:1.4.01' implementation 'xml-apis:xml-apis-ext:1.3.04' - //cryptor + // Cryptor implementation 'commons-codec:commons-codec:1.15' - // lombok + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + // Mock Web Server + testImplementation 'com.squareup.okhttp3:okhttp:4.0.1' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.0.1' } test { diff --git a/backend/src/docs/asciidoc/member.adoc b/backend/src/docs/asciidoc/member.adoc index d03eca9d2..02c34b6ae 100644 --- a/backend/src/docs/asciidoc/member.adoc +++ b/backend/src/docs/asciidoc/member.adoc @@ -25,3 +25,39 @@ include::{snippets}/member/token/success/http-request.adoc[] include::{snippets}/member/token/success/http-response.adoc[] ==== Fail Response include::{snippets}/member/token/fail/http-response.adoc[] + +=== 멤버 구글 이메일 반환 +==== Request +include::{snippets}/member/get/oauth/GOOGLE/http-request.adoc[] +==== Response +include::{snippets}/member/get/oauth/GOOGLE/http-response.adoc[] + +=== 멤버 깃헙 이메일 반환 +==== Request +include::{snippets}/member/get/oauth/GITHUB/http-request.adoc[] +==== Response +include::{snippets}/member/get/oauth/GITHUB/http-response.adoc[] + +=== 멤버 구글 회원가입 +==== Request +include::{snippets}/member/post/oauth/GOOGLE/http-request.adoc[] +==== Response +include::{snippets}/member/post/oauth/GOOGLE/http-response.adoc[] + +=== 멤버 깃헙 회원가입 +==== Request +include::{snippets}/member/post/oauth/GITHUB/http-request.adoc[] +==== Response +include::{snippets}/member/post/oauth/GITHUB/http-response.adoc[] + +=== 멤버 구글 로그인 +==== Request +include::{snippets}/member/login/oauth/GOOGLE/http-request.adoc[] +==== Response +include::{snippets}/member/login/oauth/GOOGLE/http-response.adoc[] + +=== 멤버 깃헙 로그인 +==== Request +include::{snippets}/member/login/oauth/GITHUB/http-request.adoc[] +==== Response +include::{snippets}/member/login/oauth/GITHUB/http-response.adoc[] diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java deleted file mode 100644 index 5ff18e8af..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java +++ /dev/null @@ -1,226 +0,0 @@ -package com.woowacourse.zzimkkong; - -import com.woowacourse.zzimkkong.domain.*; -import com.woowacourse.zzimkkong.repository.MapRepository; -import com.woowacourse.zzimkkong.repository.MemberRepository; -import com.woowacourse.zzimkkong.repository.ReservationRepository; -import com.woowacourse.zzimkkong.repository.SpaceRepository; -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; - -@Component -@Profile({"local"}) -public class DataLoader implements CommandLineRunner { - private final MemberRepository members; - private final MapRepository maps; - private final SpaceRepository spaces; - private final ReservationRepository reservations; - - public DataLoader( - final MemberRepository memberRepository, - final MapRepository mapRepository, - final SpaceRepository spaceRepository, - final ReservationRepository reservationRepository) { - this.members = memberRepository; - this.maps = mapRepository; - this.spaces = spaceRepository; - this.reservations = reservationRepository; - } - - @Override - public void run(String... args) { - String lectureRoomColor = "#FED7D9"; - String pairRoomColor = "#CCDFFB"; - String meetingRoomColor = "#FFE3AC"; - - Member pobi = members.save( - new Member("pobi@woowa.com", "test1234", "woowacourse") - ); - - Map luther = maps.save( - new Map( - "루터회관", - "{'id': '1', 'type': 'polyline', 'fill': '', 'stroke': 'rgba(111, 111, 111, 1)', 'points': '['60,250', '1,231', '242,252']', 'd': '[]', 'transform': ''}", - "https://d1dgzmdd5f1fx6.cloudfront.net/thumbnails/1.png", - pobi) - ); - - Setting defaultSetting = Setting.builder() - .availableStartTime(LocalTime.of(0, 0)) - .availableEndTime(LocalTime.of(23, 59)) - .reservationTimeUnit(10) - .reservationMinimumTimeUnit(10) - .reservationMaximumTimeUnit(1440) - .reservationEnable(true) - .enabledDayOfWeek(null) - .build(); - - Space be = Space.builder() - .name("백엔드 강의실") - .color(lectureRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space fe1 = Space.builder() - .name("프론트엔드 강의실1") - .color(lectureRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space fe2 = Space.builder() - .name("프론트엔드 강의실2") - .color(lectureRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space meetingRoom1 = Space.builder() - .name("회의실1") - .color(meetingRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space meetingRoom2 = Space.builder() - .name("회의실2") - .color(meetingRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space meetingRoom3 = Space.builder() - .name("회의실3") - .color(meetingRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space meetingRoom4 = Space.builder() - .name("회의실4") - .color(meetingRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space meetingRoom5 = Space.builder() - .name("회의실5") - .color(meetingRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space pairRoom1 = Space.builder() - .name("페어룸1") - .color(pairRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space pairRoom2 = Space.builder() - .name("페어룸2") - .color(pairRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space pairRoom3 = Space.builder() - .name("페어룸3") - .color(pairRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space pairRoom4 = Space.builder() - .name("페어룸4") - .color(pairRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space pairRoom5 = Space.builder() - .name("페어룸5") - .color(pairRoomColor) - .map(luther) - .setting(defaultSetting) - .build(); - - Space trackRoom = Space.builder() - .name("트랙방") - .color("#D8FBCC") - .map(luther) - .setting(defaultSetting) - .build(); - - List sampleSpaces = List.of( - be, - fe1, fe2, - meetingRoom1, meetingRoom2, meetingRoom3, meetingRoom4, meetingRoom5, - pairRoom1, pairRoom2, pairRoom3, pairRoom4, pairRoom5, - trackRoom - ); - - for (Space space : sampleSpaces) { - spaces.save(space); - } - - LocalDate targetDate = LocalDate.now().plusDays(1L); - - Reservation reservationBackEndTargetDate0To1 = Reservation.builder() - .startTime(targetDate.atStartOfDay()) - .endTime(targetDate.atTime(1, 0, 0)) - .description("찜꽁 1차 회의") - .userName("찜꽁") - .password("1234") - .space(be) - .build(); - - Reservation reservationBackEndTargetDate13To14 = Reservation.builder() - .startTime(targetDate.atTime(13, 0, 0)) - .endTime(targetDate.atTime(14, 0, 0)) - .description("찜꽁 2차 회의") - .userName("찜꽁") - .password("1234") - .space(be) - .build(); - - Reservation reservationBackEndTargetDate18To23 = Reservation.builder() - .startTime(targetDate.atTime(18, 0, 0)) - .endTime(targetDate.atTime(23, 59, 59)) - .description("찜꽁 3차 회의") - .userName("찜꽁") - .password("6789") - .space(be) - .build(); - - Reservation reservationBackEndTheDayAfterTargetDate = Reservation.builder() - .startTime(targetDate.plusDays(1L).atStartOfDay()) - .endTime(targetDate.plusDays(1L).atTime(1, 0, 0)) - .description("찜꽁 4차 회의") - .userName("찜꽁") - .password("1234") - .space(be) - .build(); - - Reservation reservationFrontEnd1TargetDate0to1 = Reservation.builder() - .startTime(targetDate.atStartOfDay()) - .endTime(targetDate.atTime(1, 0, 0)) - .description("찜꽁 5차 회의") - .userName("찜꽁") - .password("1234") - .space(fe1) - .build(); - - reservations.save(reservationBackEndTargetDate0To1); - reservations.save(reservationBackEndTargetDate13To14); - reservations.save(reservationBackEndTargetDate18To23); - reservations.save(reservationBackEndTheDayAfterTargetDate); - reservations.save(reservationFrontEnd1TargetDate0to1); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java index d1d45f2ae..ac98973fa 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java @@ -26,11 +26,29 @@ public void addArgumentResolvers(List argumentResolvers) { @Override public void addInterceptors(InterceptorRegistry registry) { List pathsToAdd = List.of( - "/api/members/token", + "/api/managers/token", "/api/managers/**" ); + List pathsToExclude = List.of( + //manager join + "/api/managers", + "/api/managers/GOOGLE", + "/api/managers/GITHUB", + "/api/managers/google", + "/api/managers/github", + "/api/managers/oauth", + + //manager login + "/api/managers/login/token", + "/api/managers/GOOGLE/login/token", + "/api/managers/GITHUB/login/token", + "/api/managers/google/login/token", + "/api/managers/github/login/token" + ); + registry.addInterceptor(loginInterceptor) - .addPathPatterns(pathsToAdd); + .addPathPatterns(pathsToAdd) + .excludePathPatterns(pathsToExclude); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthConfig.java new file mode 100644 index 000000000..e599ded4b --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthConfig.java @@ -0,0 +1,14 @@ +package com.woowacourse.zzimkkong.config; + +import com.woowacourse.zzimkkong.infrastructure.oauth.StringToOauthProviderConverter; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class OauthConfig implements WebMvcConfigurer { + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(new StringToOauthProviderConverter()); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthGithubConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthGithubConfig.java new file mode 100644 index 000000000..e95e9d085 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/OauthGithubConfig.java @@ -0,0 +1,42 @@ +package com.woowacourse.zzimkkong.config; + +import com.woowacourse.zzimkkong.infrastructure.oauth.GithubRequester; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource("classpath:config/oauth.properties") +public class OauthGithubConfig { + @Bean(name = "githubRequester") + @Profile({"prod"}) + public GithubRequester githubRequesterProd( + @Value("${github.client-id.prod}") final String clientId, + @Value("${github.secret-id.prod}") final String secretId, + @Value("${github.url.oauth-login}") final String githubOauthUrl, + @Value("${github.url.open-api}") final String githubOpenApiUrl) { + return new GithubRequester(clientId, secretId, githubOauthUrl, githubOpenApiUrl); + } + + @Bean(name = "githubRequester") + @Profile({"dev"}) + public GithubRequester githubRequesterDev( + @Value("${github.client-id.dev}") final String clientId, + @Value("${github.secret-id.dev}") final String secretId, + @Value("${github.url.oauth-login}") final String githubOauthUrl, + @Value("${github.url.open-api}") final String githubOpenApiUrl) { + return new GithubRequester(clientId, secretId, githubOauthUrl, githubOpenApiUrl); + } + + @Bean(name = "githubRequester") + @Profile({"local", "test"}) + public GithubRequester githubRequesterLocalTest( + @Value("${github.client-id.local}") final String clientId, + @Value("${github.secret-id.local}") final String secretId, + @Value("${github.url.oauth-login}") final String githubOauthUrl, + @Value("${github.url.open-api}") final String githubOpenApiUrl) { + return new GithubRequester(clientId, secretId, githubOauthUrl, githubOpenApiUrl); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java index f4f9229d0..b276c93e3 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java @@ -1,18 +1,16 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; import com.woowacourse.zzimkkong.service.AuthService; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import javax.validation.Valid; @RestController -@RequestMapping("/api") +@RequestMapping("/api/managers") public class AuthController { private final AuthService authService; @@ -26,8 +24,14 @@ public ResponseEntity login(@RequestBody @Valid final LoginReques .body(authService.login(loginRequest)); } - @PostMapping("/members/token") + @PostMapping("/token") public ResponseEntity token() { return ResponseEntity.ok().build(); } + + @GetMapping("/{oauthProvider}/login/token") + public ResponseEntity loginByOauth(@PathVariable OauthProvider oauthProvider, @RequestParam String code) { + return ResponseEntity.ok() + .body(authService.loginByOauth(oauthProvider, code)); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java index 39dcfac44..2c27fb1ba 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java @@ -23,8 +23,8 @@ public ManagerSpaceController(final SpaceService spaceService) { public ResponseEntity save( @PathVariable final Long mapId, @RequestBody @Valid final SpaceCreateUpdateRequest spaceCreateRequest, - @Manager final Member manager) { - SpaceCreateResponse spaceCreateResponse = spaceService.saveSpace(mapId, spaceCreateRequest, manager); + @Manager final Member member) { + SpaceCreateResponse spaceCreateResponse = spaceService.saveSpace(mapId, spaceCreateRequest, member); return ResponseEntity .created(URI.create("/api/managers/maps/" + mapId + "/spaces/" + spaceCreateResponse.getId())) .build(); diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java index 59a5b22c2..c6dc58590 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java @@ -2,7 +2,10 @@ import com.woowacourse.zzimkkong.domain.Manager; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.dto.member.*; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.service.MemberService; import com.woowacourse.zzimkkong.service.PresetService; import org.springframework.http.ResponseEntity; @@ -18,7 +21,7 @@ import static com.woowacourse.zzimkkong.dto.ValidatorMessage.EMPTY_MESSAGE; @RestController -@RequestMapping("/api/members") +@RequestMapping("/api/managers") @Validated public class MemberController { private final MemberService memberService; @@ -33,7 +36,22 @@ public MemberController(final MemberService memberService, final PresetService p public ResponseEntity join(@RequestBody @Valid final MemberSaveRequest memberSaveRequest) { MemberSaveResponse memberSaveResponse = memberService.saveMember(memberSaveRequest); return ResponseEntity - .created(URI.create("/api/members/" + memberSaveResponse.getId())) + .created(URI.create("/api/managers/" + memberSaveResponse.getId())) + .build(); + } + + @GetMapping("/{oauthProvider}") + public ResponseEntity getReadyToJoinByOauth(@PathVariable OauthProvider oauthProvider, @RequestParam String code) { + OauthReadyResponse oauthReadyResponse = memberService.getUserInfoFromOauth(oauthProvider, code); + return ResponseEntity + .ok(oauthReadyResponse); + } + + @PostMapping("/oauth") + public ResponseEntity joinByOauth(@RequestBody @Valid final OauthMemberSaveRequest oauthMemberSaveRequest) { + MemberSaveResponse memberSaveResponse = memberService.saveMemberByOauth(oauthMemberSaveRequest); + return ResponseEntity + .created(URI.create("/api/managers/" + memberSaveResponse.getId())) .build(); } @@ -41,7 +59,8 @@ public ResponseEntity join(@RequestBody @Valid final MemberSaveRequest mem public ResponseEntity validateEmail( @RequestParam @NotBlank(message = EMPTY_MESSAGE) - @Email(message = EMAIL_MESSAGE) final String email) { + @Email(message = EMAIL_MESSAGE) + final String email) { memberService.validateDuplicateEmail(email); return ResponseEntity.ok().build(); } @@ -52,7 +71,7 @@ public ResponseEntity createPreset( @Manager final Member manager) { PresetCreateResponse presetCreateResponse = presetService.savePreset(presetCreateRequest, manager); return ResponseEntity - .created(URI.create("/api/members/presets/" + presetCreateResponse.getId())) + .created(URI.create("/api/managers/presets/" + presetCreateResponse.getId())) .build(); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java index 26c72f006..e166ba677 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java @@ -20,7 +20,7 @@ public class Member { @Column(nullable = false, length = 50, unique = true) private String email; - @Column(nullable = false, length = 128) + @Column(length = 128) private String password; @Column(nullable = false, length = 20) @@ -29,6 +29,10 @@ public class Member { @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) private List presets = new ArrayList<>(); + @Column(length = 10) + @Enumerated(EnumType.STRING) + private OauthProvider oauthProvider; + public Member( final String email, final String password, @@ -47,6 +51,17 @@ public Member( this.id = id; } + public Member(final String email, final String organization, final OauthProvider oauthProvider) { + this.email = email; + this.organization = organization; + this.oauthProvider = oauthProvider; + } + + public Member(final Long id, final String email, final String organization, final OauthProvider oauthProvider) { + this(email, organization, oauthProvider); + this.id = id; + } + public Optional findPresetById(final Long presetId) { return this.presets.stream() .filter(preset -> preset.hasSameId(presetId)) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/OauthProvider.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/OauthProvider.java new file mode 100644 index 000000000..a75614f15 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/OauthProvider.java @@ -0,0 +1,20 @@ +package com.woowacourse.zzimkkong.domain; + +import com.woowacourse.zzimkkong.exception.infrastructure.UnsupportedOauthProviderException; + +import java.util.Arrays; + +public enum OauthProvider { + GITHUB, GOOGLE; + + public static OauthProvider valueOfWithIgnoreCase(String value) { + return Arrays.stream(values()) + .filter(oauthProvider -> oauthProvider.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(UnsupportedOauthProviderException::new); + } + + public boolean isSameAs(OauthProvider oauthProvider) { + return this.equals(oauthProvider); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfo.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfo.java new file mode 100644 index 000000000..f4165f022 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfo.java @@ -0,0 +1,30 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.NoPublicEmailOnGithubException; + +import java.util.Collections; +import java.util.Map; + +public class GithubUserInfo implements OauthUserInfo { + private final Map info; + + private GithubUserInfo(final Map info) { + this.info = Collections.unmodifiableMap(info); + } + + public static OauthUserInfo from(Map responseBody) { + return new GithubUserInfo(responseBody); + } + + @Override + public String getEmail() { + validatePublicEmailHasBeenSet(); + return (String) info.get("email"); + } + + private void validatePublicEmailHasBeenSet() { + if (info.get("email") == null) { + throw new NoPublicEmailOnGithubException(); + } + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GoogleUserInfo.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GoogleUserInfo.java new file mode 100644 index 000000000..6d0898747 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/GoogleUserInfo.java @@ -0,0 +1,27 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.Map; + +@Getter +@NoArgsConstructor +public class GoogleUserInfo implements OauthUserInfo { + private Map info; + + private GoogleUserInfo(final Map info) { + this.info = Collections.unmodifiableMap(info); + } + + public static GoogleUserInfo from(final Map responseBody) { + return new GoogleUserInfo(responseBody); + } + + @Override + public String getEmail() { + return (String) info.get("email"); + } +} + diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/OauthUserInfo.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/OauthUserInfo.java new file mode 100644 index 000000000..5d0080ce1 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/oauth/OauthUserInfo.java @@ -0,0 +1,5 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +public interface OauthUserInfo { + String getEmail(); +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthMemberSaveRequest.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthMemberSaveRequest.java new file mode 100644 index 000000000..4dc67b739 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthMemberSaveRequest.java @@ -0,0 +1,34 @@ +package com.woowacourse.zzimkkong.dto.member.oauth; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.*; + +@Getter +@NoArgsConstructor +public class OauthMemberSaveRequest { + @NotBlank(message = EMPTY_MESSAGE) + @Email(message = EMAIL_MESSAGE) + private String email; + + @NotNull(message = EMPTY_MESSAGE) + @Pattern(regexp = ORGANIZATION_FORMAT, message = ORGANIZATION_MESSAGE) + private String organization; + + @NotNull(message = EMPTY_MESSAGE) + private String oauthProvider; + + public OauthMemberSaveRequest(final String email, + final String organization, + final String oauthProvider) { + this.email = email; + this.organization = organization; + this.oauthProvider = oauthProvider; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java new file mode 100644 index 000000000..ba6f1e417 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java @@ -0,0 +1,21 @@ +package com.woowacourse.zzimkkong.dto.member.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class OauthReadyResponse { + private String email; + private OauthProvider oauthProvider; + + private OauthReadyResponse(final String email, final OauthProvider oauthProvider) { + this.email = email; + this.oauthProvider = oauthProvider; + } + + public static OauthReadyResponse of(final String email, final OauthProvider oauthProvider) { + return new OauthReadyResponse(email, oauthProvider); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java new file mode 100644 index 000000000..cb145ff9a --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.authorization; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class OauthProviderMismatchException extends ZzimkkongException { + private static final String MESSAGE = "소셜 로그인 제공자가 다릅니다. %s를 통해 로그인하세요."; + + public OauthProviderMismatchException(final String oauthProvider) { + super(String.format(MESSAGE, oauthProvider), HttpStatus.UNAUTHORIZED); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/UnsupportedOauthProviderException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/UnsupportedOauthProviderException.java new file mode 100644 index 000000000..382f87cd8 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/UnsupportedOauthProviderException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class UnsupportedOauthProviderException extends ZzimkkongException { + private static final String MESSAGE = "지원되지 않는 Oauth 제공자입니다."; + + public UnsupportedOauthProviderException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetGithubAccessTokenException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetGithubAccessTokenException.java new file mode 100644 index 000000000..f65f74cf4 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetGithubAccessTokenException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class ErrorResponseToGetGithubAccessTokenException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "소셜 로그인에 실패했습니다. 다시 시도해주세요."; + + public ErrorResponseToGetGithubAccessTokenException(String errorMessageFromGithub) { + super(MESSAGE, new Throwable(errorMessageFromGithub), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/NoPublicEmailOnGithubException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/NoPublicEmailOnGithubException.java new file mode 100644 index 000000000..007123bf5 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/NoPublicEmailOnGithubException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class NoPublicEmailOnGithubException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "소셜 로그인에 실패했습니다. 깃허브의 Public Email(Setting -> Profile)을 설정해주시기 바랍니다."; + + public NoPublicEmailOnGithubException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGithubException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGithubException.java new file mode 100644 index 000000000..58320c894 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGithubException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class UnableToGetTokenResponseFromGithubException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "소셜 로그인에 실패했습니다."; + + public UnableToGetTokenResponseFromGithubException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGoogleException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGoogleException.java new file mode 100644 index 000000000..5f79fbe12 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/UnableToGetTokenResponseFromGoogleException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import org.springframework.http.HttpStatus; + +public class UnableToGetTokenResponseFromGoogleException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "구글 소셜 로그인에 실패했습니다."; + + public UnableToGetTokenResponseFromGoogleException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java index b5b0fe1c7..a288ea443 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/map/NoSuchMapException.java @@ -7,6 +7,6 @@ public class NoSuchMapException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 맵입니다."; public NoSuchMapException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java index d9bd3f153..27a2ca087 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchMemberException.java @@ -7,6 +7,6 @@ public class NoSuchMemberException extends InputFieldException { private static final String MESSAGE = "존재하지 않는 회원입니다."; public NoSuchMemberException() { - super(MESSAGE, HttpStatus.BAD_REQUEST, EMAIL); + super(MESSAGE, HttpStatus.NOT_FOUND, EMAIL); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java index a791b1563..c063738d6 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/preset/NoSuchPresetException.java @@ -7,6 +7,6 @@ public class NoSuchPresetException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 프리셋입니다."; public NoSuchPresetException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java index 8ace9ae7d..60bca6a3b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/reservation/NoSuchReservationException.java @@ -7,6 +7,6 @@ public class NoSuchReservationException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 예약입니다."; public NoSuchReservationException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java index d6df8dddf..f59a6e3be 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchDayOfWeekException.java @@ -7,6 +7,6 @@ public class NoSuchDayOfWeekException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 요일입니다."; public NoSuchDayOfWeekException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java index f5648169e..457c306f4 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/space/NoSuchSpaceException.java @@ -7,6 +7,6 @@ public class NoSuchSpaceException extends ZzimkkongException { private static final String MESSAGE = "존재하지 않는 공간입니다."; public NoSuchSpaceException() { - super(MESSAGE, HttpStatus.BAD_REQUEST); + super(MESSAGE, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java index 35f3e3a2a..c229f20c3 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/JwtUtils.java @@ -46,7 +46,7 @@ public void validateToken(String token) { } public String getPayload(String token) { - return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + return jwtParser.parseClaimsJws(token).getBody().getSubject(); } public static PayloadBuilder payloadBuilder() { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java new file mode 100644 index 000000000..f957c5da0 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java @@ -0,0 +1,91 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.UnableToGetTokenResponseFromGithubException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Map; + +@PropertySource("classpath:config/oauth.properties") +public class GithubRequester implements OauthAPIRequester { + private final String clientId; + private final String secretId; + private final WebClient githubOauthLoginClient; + private final WebClient githubOpenApiClient; + + public GithubRequester( + final String clientId, + final String secretId, + final String githubOauthUrl, + final String githubOpenApiUrl) { + this.clientId = clientId; + this.secretId = secretId; + this.githubOauthLoginClient = githubOauthLoginClient(githubOauthUrl); + this.githubOpenApiClient = githubOpenApiClient(githubOpenApiUrl); + } + + @Override + public boolean supports(final OauthProvider oauthProvider) { + return oauthProvider.isSameAs(OauthProvider.GITHUB); + } + + @Override + public OauthUserInfo getUserInfoByCode(final String code) { + String token = getToken(code); + return getUserInfo(token); + } + + private String getToken(final String code) { + Map responseBody = githubOauthLoginClient + .post() + .uri(uriBuilder -> uriBuilder + .path("/access_token") + .queryParam("code", code) + .queryParam("client_id", clientId) + .queryParam("client_secret", secretId) + .build()) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGithubException::new); + + return responseBody.get("access_token").toString(); + } + + private OauthUserInfo getUserInfo(final String token) { + Map responseBody = githubOpenApiClient + .get() + .uri("/user") + .header(HttpHeaders.AUTHORIZATION, "token " + token) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGithubException::new); + + return GithubUserInfo.from(responseBody); + } + + private WebClient githubOauthLoginClient(String githubOauthUrl) { + return WebClient.builder() + .baseUrl(githubOauthUrl) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + private WebClient githubOpenApiClient(String githubOpenApiUrl) { + return WebClient.builder() + .baseUrl(githubOpenApiUrl) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java new file mode 100644 index 000000000..24b22c1a4 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java @@ -0,0 +1,101 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.UnableToGetTokenResponseFromGoogleException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +@Component +@PropertySource("classpath:config/oauth.properties") +public class GoogleRequester implements OauthAPIRequester { + private final String clientId; + private final String secretId; + private final String redirectUri; + private final String baseLoginUri; + private final String baseUserUri; + + public GoogleRequester( + @Value("${google.client-id}") final String clientId, + @Value("${google.secret-id}") final String secretId, + @Value("${google.uri.redirect}") final String redirectUri, + @Value("${google.uri.oauth-login}") final String baseLoginUri, + @Value("${google.uri.user-info}") final String baseUserUri) { + this.clientId = clientId; + this.secretId = secretId; + this.redirectUri = redirectUri; + this.baseLoginUri = baseLoginUri; + this.baseUserUri = baseUserUri; + } + + @Override + public boolean supports(final OauthProvider oauthProvider) { + return oauthProvider.isSameAs(OauthProvider.GOOGLE); + } + + @Override + public OauthUserInfo getUserInfoByCode(final String code) { + String token = getToken(code); + return getUserInfo(token); + } + + private String getToken(final String code) { + Map responseBody = googleOauthLoginClient() + .post() + .uri(uriBuilder -> uriBuilder + .queryParam("code", code) + .queryParam("client_id", clientId) + .queryParam("client_secret", secretId) + .queryParam("redirect_uri", redirectUri) + .queryParam("grant_type", "authorization_code") + .build()) + .headers(header -> { + header.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8)); + }) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGoogleException::new); + + return responseBody.get("access_token").toString(); + } + + private GoogleUserInfo getUserInfo(final String token) { + Map responseBody = googleUserClient() + .get() + .headers(httpHeaders -> httpHeaders.setBearerAuth(token)) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .blockOptional() + .orElseThrow(UnableToGetTokenResponseFromGoogleException::new); + + return GoogleUserInfo.from(responseBody); + } + + private WebClient googleOauthLoginClient() { + return WebClient.builder() + .baseUrl(baseLoginUri) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + private WebClient googleUserClient() { + return WebClient.builder() + .baseUrl(baseUserUri) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthAPIRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthAPIRequester.java new file mode 100644 index 000000000..05154d89a --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthAPIRequester.java @@ -0,0 +1,10 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; + +public interface OauthAPIRequester { + boolean supports(OauthProvider oauthProvider); + + OauthUserInfo getUserInfoByCode(String code); +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java new file mode 100644 index 000000000..b6dec0f26 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java @@ -0,0 +1,30 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.UnsupportedOauthProviderException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class OauthHandler { + private final List oauthAPIRequesters; + + public OauthHandler(final List oauthAPIRequesters) { + this.oauthAPIRequesters = oauthAPIRequesters; + } + + public OauthUserInfo getUserInfoFromCode(final OauthProvider oauthProvider, final String code) { + OauthAPIRequester requester = getRequester(oauthProvider); + return requester.getUserInfoByCode(code); + } + + private OauthAPIRequester getRequester(final OauthProvider oauthProvider) { + return oauthAPIRequesters.stream() + .filter(requester -> requester.supports(oauthProvider)) + .findFirst() + .orElseThrow(UnsupportedOauthProviderException::new); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/StringToOauthProviderConverter.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/StringToOauthProviderConverter.java new file mode 100644 index 000000000..9c67f24a1 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/StringToOauthProviderConverter.java @@ -0,0 +1,13 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import org.springframework.core.convert.converter.Converter; + +import java.util.Locale; + +public class StringToOauthProviderConverter implements Converter { + @Override + public OauthProvider convert(String source) { + return OauthProvider.valueOf(source.toUpperCase(Locale.ROOT)); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java index 8e3ac263a..619214041 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java @@ -1,11 +1,15 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; import com.woowacourse.zzimkkong.infrastructure.JwtUtils; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import com.woowacourse.zzimkkong.repository.MemberRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -19,17 +23,20 @@ public class AuthService { private final MemberRepository members; private final JwtUtils jwtUtils; private final PasswordEncoder passwordEncoder; + private final OauthHandler oauthHandler; public AuthService(final MemberRepository members, final JwtUtils jwtUtils, - final PasswordEncoder passwordEncoder) { + final PasswordEncoder passwordEncoder, + final OauthHandler oauthHandler) { this.members = members; this.jwtUtils = jwtUtils; this.passwordEncoder = passwordEncoder; + this.oauthHandler = oauthHandler; } @Transactional(readOnly = true) - public TokenResponse login(LoginRequest loginRequest) { + public TokenResponse login(final LoginRequest loginRequest) { Member findMember = members.findByEmail(loginRequest.getEmail()) .orElseThrow(NoSuchMemberException::new); @@ -40,7 +47,20 @@ public TokenResponse login(LoginRequest loginRequest) { return TokenResponse.from(token); } - private String issueToken(Member findMember) { + @Transactional(readOnly = true) + public TokenResponse loginByOauth(final OauthProvider oauthProvider, final String code) { + OauthUserInfo userInfoFromCode = oauthHandler.getUserInfoFromCode(oauthProvider, code); + String email = userInfoFromCode.getEmail(); + Member member = members.findByEmail(email) + .orElseThrow(NoSuchMemberException::new); + + validateOauthProvider(oauthProvider, member); + + String token = issueToken(member); + return TokenResponse.from(token); + } + + private String issueToken(final Member findMember) { Map payload = JwtUtils.payloadBuilder() .setSubject(findMember.getEmail()) .build(); @@ -48,9 +68,16 @@ private String issueToken(Member findMember) { return jwtUtils.createToken(payload); } - private void validatePassword(Member findMember, String password) { + private void validatePassword(final Member findMember, final String password) { if (!passwordEncoder.matches(password, findMember.getPassword())) { throw new PasswordMismatchException(); } } + + private void validateOauthProvider(final OauthProvider oauthProvider, final Member member) { + OauthProvider memberOauthProvider = member.getOauthProvider(); + if (!oauthProvider.equals(memberOauthProvider)) { + throw new OauthProviderMismatchException(memberOauthProvider.name()); + } + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java index 4226aa1ab..2b3dd81c9 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java @@ -1,9 +1,14 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import com.woowacourse.zzimkkong.repository.MemberRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -14,11 +19,14 @@ public class MemberService { private final MemberRepository members; private final PasswordEncoder passwordEncoder; + private final OauthHandler oauthHandler; public MemberService(final MemberRepository members, - final PasswordEncoder passwordEncoder) { + final PasswordEncoder passwordEncoder, + final OauthHandler oauthHandler) { this.members = members; this.passwordEncoder = passwordEncoder; + this.oauthHandler = oauthHandler; } public MemberSaveResponse saveMember(final MemberSaveRequest memberSaveRequest) { @@ -34,6 +42,28 @@ public MemberSaveResponse saveMember(final MemberSaveRequest memberSaveRequest) return MemberSaveResponse.from(saveMember); } + @Transactional(readOnly = true) + public OauthReadyResponse getUserInfoFromOauth(final OauthProvider oauthProvider, final String code) { + OauthUserInfo userInfo = oauthHandler.getUserInfoFromCode(oauthProvider, code); + String email = userInfo.getEmail(); + + validateDuplicateEmail(email); + + return OauthReadyResponse.of(email, oauthProvider); + } + + public MemberSaveResponse saveMemberByOauth(final OauthMemberSaveRequest oauthMemberSaveRequest) { + validateDuplicateEmail(oauthMemberSaveRequest.getEmail()); + + Member member = new Member( + oauthMemberSaveRequest.getEmail(), + oauthMemberSaveRequest.getOrganization(), + OauthProvider.valueOfWithIgnoreCase(oauthMemberSaveRequest.getOauthProvider()) + ); + Member saveMember = members.save(member); + return MemberSaveResponse.from(saveMember); + } + @Transactional(readOnly = true) public void validateDuplicateEmail(final String email) { if (members.existsByEmail(email)) { diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index 1eba39047..bd67a1a3f 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit 1eba39047270b72c09b9b5e63af6e51bd7b6c8fa +Subproject commit bd67a1a3f66f495d4e38b9ddfd3df65fbe0f5a88 diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/config/StringToOauthProviderConverterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/config/StringToOauthProviderConverterTest.java new file mode 100644 index 000000000..695299096 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/config/StringToOauthProviderConverterTest.java @@ -0,0 +1,24 @@ +package com.woowacourse.zzimkkong.config; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.infrastructure.oauth.StringToOauthProviderConverter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class StringToOauthProviderConverterTest { + @Test + @DisplayName("Oauth 제공사 이름 문자열로부터 enum 객체를 찾을 수 있다.") + void convert() { + // given + String oauthProvider = "Github"; + + // when + StringToOauthProviderConverter stringToOauthProviderConverter = new StringToOauthProviderConverter(); + OauthProvider actual = stringToOauthProviderConverter.convert(oauthProvider); + + // then + assertThat(actual).isSameAs(OauthProvider.GITHUB); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java index d3dcc38ed..cb781ac60 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java @@ -6,6 +6,8 @@ import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.dto.space.SpaceCreateUpdateRequest; import com.woowacourse.zzimkkong.infrastructure.StorageUploader; +import com.woowacourse.zzimkkong.infrastructure.oauth.GithubRequester; +import com.woowacourse.zzimkkong.infrastructure.oauth.GoogleRequester; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.specification.RequestSpecification; @@ -88,6 +90,12 @@ class AcceptanceTest { @Autowired protected PasswordEncoder passwordEncoder; + @MockBean + protected GithubRequester githubRequester; + + @MockBean + protected GoogleRequester googleRequester; + @BeforeEach void setUp(RestDocumentationContextProvider restDocumentation) { RestAssured.port = port; diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java index 0f1525a09..a50f12048 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AuthControllerTest.java @@ -1,6 +1,10 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; import com.woowacourse.zzimkkong.dto.member.LoginRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; import com.woowacourse.zzimkkong.infrastructure.AuthorizationExtractor; import io.restassured.RestAssured; @@ -11,9 +15,17 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import java.util.Map; + +import static com.woowacourse.zzimkkong.Constants.NEW_EMAIL; +import static com.woowacourse.zzimkkong.Constants.ORGANIZATION; import static com.woowacourse.zzimkkong.DocumentUtils.*; import static com.woowacourse.zzimkkong.controller.MemberControllerTest.saveMember; +import static com.woowacourse.zzimkkong.controller.MemberControllerTest.saveMemberByOauth; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document; class AuthControllerTest extends AcceptanceTest { @@ -32,6 +44,53 @@ void login() { assertThat(responseBody.getAccessToken()).isInstanceOf(String.class); } + @Test + @DisplayName("Github Oauth 로그인 요청이 오면 토큰을 발급한다.") + void loginByGithubOauth() { + // given + OauthProvider oauthProvider = OauthProvider.GITHUB; + saveMemberByOauth(new OauthMemberSaveRequest(NEW_EMAIL, ORGANIZATION, oauthProvider.name())); + String code = "example-code"; + + given(githubRequester.supports(OauthProvider.GITHUB)) + .willReturn(true); + given(githubRequester.getUserInfoByCode(code)) + .willReturn(GithubUserInfo.from(Map.of("email", NEW_EMAIL))); + + // when + ExtractableResponse response = loginByOauth(oauthProvider, code); + TokenResponse responseBody = response.body().as(TokenResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(responseBody.getAccessToken()).isInstanceOf(String.class); + + } + + @Test + @DisplayName("Google Oauth 로그인 요청이 오면 토큰을 발급한다.") + void loginByGoogleOauth() { + // given + OauthProvider oauthProvider = OauthProvider.GOOGLE; + saveMemberByOauth(new OauthMemberSaveRequest(NEW_EMAIL, ORGANIZATION, oauthProvider.name())); + String code = "example-code"; + + given(googleRequester.supports(any(OauthProvider.class))) + .willReturn(true); + given(googleRequester.getUserInfoByCode(anyString())) + .willReturn(GoogleUserInfo.from( + Map.of("id", "123", + "email", NEW_EMAIL))); + + // when + ExtractableResponse response = loginByOauth(oauthProvider, code); + TokenResponse responseBody = response.body().as(TokenResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(responseBody.getAccessToken()).isInstanceOf(String.class); + } + @Test @DisplayName("유효한 토큰으로 요청이 오면 200 ok가 반환된다.") void validToken() { @@ -73,7 +132,17 @@ static ExtractableResponse login(final LoginRequest loginRequest) { .filter(document("member/login", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(loginRequest) - .when().post("/api/login/token") + .when().post("/api/managers/login/token") + .then().log().all().extract(); + } + + static ExtractableResponse loginByOauth(final OauthProvider oauthProvider, final String code) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .filter(document("member/login/oauth/" + oauthProvider.name(), getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/api/managers/" + oauthProvider + "/login/token?code=" + code) .then().log().all().extract(); } @@ -84,7 +153,7 @@ private ExtractableResponse token(final String token, final String doc .header("Authorization", token) .filter(document("member/token/" + docName, getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) - .when().post("/api/members/token") + .when().post("/api/managers/token") .then().log().all().extract(); } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java index f033aa5fe..676d86cd8 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java @@ -1,11 +1,14 @@ package com.woowacourse.zzimkkong.controller; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.Preset; import com.woowacourse.zzimkkong.domain.Setting; -import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; -import com.woowacourse.zzimkkong.dto.member.PresetFindAllResponse; -import com.woowacourse.zzimkkong.dto.member.PresetCreateRequest; +import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; +import com.woowacourse.zzimkkong.dto.member.*; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.infrastructure.AuthorizationExtractor; import io.restassured.RestAssured; @@ -14,16 +17,20 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.security.crypto.password.PasswordEncoder; import java.util.List; +import java.util.Map; import static com.woowacourse.zzimkkong.Constants.*; import static com.woowacourse.zzimkkong.DocumentUtils.*; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document; class MemberControllerTest extends AcceptanceTest { @@ -71,6 +78,65 @@ void join() { assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); } + @Test + @DisplayName("Google Oauth 회원가입 입력이 들어오면 accessToken을 발급한다.") + void getReadyToJoinByGoogleOauth() { + // given + given(googleRequester.supports(any(OauthProvider.class))) + .willReturn(true); + given(googleRequester.getUserInfoByCode(anyString())) + .willReturn(GoogleUserInfo.from( + Map.of("id", "123", + "email", NEW_EMAIL))); + + OauthProvider oauthProvider = OauthProvider.GOOGLE; + String code = "example-code"; + + // when + ExtractableResponse response = getReadyToJoin(oauthProvider, code); + OauthReadyResponse expected = OauthReadyResponse.of(NEW_EMAIL, oauthProvider); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.body().as(OauthReadyResponse.class)).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + @DisplayName("Github Oauth 회원가입 입력이 들어오면 accessToken을 발급한다.") + void getReadyToJoinByGithubOauth() { + // given + given(githubRequester.supports(any(OauthProvider.class))) + .willReturn(true); + given(githubRequester.getUserInfoByCode(anyString())) + .willReturn(GithubUserInfo.from(Map.of("email", NEW_EMAIL))); + + OauthProvider oauthProvider = OauthProvider.GITHUB; + String code = "example-code"; + + // when + ExtractableResponse response = getReadyToJoin(oauthProvider, code); + OauthReadyResponse oauthReadyResponse = response.as(OauthReadyResponse.class); + + // then + assertThat(oauthReadyResponse.getOauthProvider()).isEqualTo(oauthProvider); + assertThat(oauthReadyResponse.getEmail()).isEqualTo(NEW_EMAIL); + } + + @ParameterizedTest + @ValueSource(strings = {"GOOGLE", "GITHUB"}) + @DisplayName("Oauth을 이용해 회원가입한다.") + void joinByOauth(String oauth) { + // given + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(NEW_EMAIL, ORGANIZATION, oauth); + + // when + ExtractableResponse response = saveMemberByOauth(oauthMemberSaveRequest); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + } + @Test @DisplayName("이메일 중복 확인 시, 중복되지 않은 이메일을 입력하면 통과한다.") void getMembers() { @@ -138,7 +204,28 @@ static ExtractableResponse saveMember(final MemberSaveRequest memberSa .filter(document("member/post", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(memberSaveRequest) - .when().post("/api/members") + .when().post("/api/managers") + .then().log().all().extract(); + } + + static ExtractableResponse getReadyToJoin(final OauthProvider oauthProvider, final String code) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .filter(document("member/get/oauth/" + oauthProvider.name(), getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/api/managers/" + oauthProvider + "?code=" + code) + .then().log().all().extract(); + } + + static ExtractableResponse saveMemberByOauth(final OauthMemberSaveRequest oauthMemberSaveRequest) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .filter(document("member/post/oauth/" + oauthMemberSaveRequest.getOauthProvider(), getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(oauthMemberSaveRequest) + .when().post("/api/managers/oauth") .then().log().all().extract(); } @@ -149,7 +236,7 @@ private ExtractableResponse validateDuplicateEmail(final String email) .filter(document("member/get", getRequestPreprocessor(), getResponsePreprocessor())) .queryParam("email", email) .contentType(MediaType.APPLICATION_JSON_VALUE) - .when().get("/api/members") + .when().get("/api/managers") .then().log().all().extract(); } @@ -161,7 +248,7 @@ private ExtractableResponse savePreset(final PresetCreateRequest prese .filter(document("preset/post", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(presetCreateRequest) - .when().post("/api/members/presets") + .when().post("/api/managers/presets") .then().log().all().extract(); } @@ -172,7 +259,7 @@ private ExtractableResponse findAllPresets() { .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) .filter(document("preset/getAll", getRequestPreprocessor(), getResponsePreprocessor())) .contentType(MediaType.APPLICATION_JSON_VALUE) - .when().get("/api/members/presets") + .when().get("/api/managers/presets") .then().log().all().extract(); } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfoTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfoTest.java new file mode 100644 index 000000000..c533e347f --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/oauth/GithubUserInfoTest.java @@ -0,0 +1,40 @@ +package com.woowacourse.zzimkkong.domain.oauth; + +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.NoPublicEmailOnGithubException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GithubUserInfoTest { + @Test + @DisplayName("map으로 받아온 정보로부터 email을 가져온다.") + void getEmail() { + //given + String email = "email@email.com"; + Map info = Map.of("email", email); + + //when + OauthUserInfo githubUserInfo = GithubUserInfo.from(info); + + //then + assertThat(githubUserInfo.getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("map으로 받아온 정보에 email이 존재하지 않으면 오류가 발생한다.") + void getEmailException() { + //given + Map info = Map.of("name", "name"); + + //when + OauthUserInfo githubUserInfo = GithubUserInfo.from(info); + + //then + assertThatThrownBy(githubUserInfo::getEmail) + .isInstanceOf(NoPublicEmailOnGithubException.class); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/OauthMemberSaveRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/OauthMemberSaveRequestTest.java new file mode 100644 index 000000000..bf4f25cde --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/OauthMemberSaveRequestTest.java @@ -0,0 +1,68 @@ +package com.woowacourse.zzimkkong.dto; + +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.NullSource; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.*; +import static org.assertj.core.api.Assertions.assertThat; + +class OauthMemberSaveRequestTest extends RequestTest { + @ParameterizedTest + @NullAndEmptySource + @DisplayName("oauth 회원가입 이메일에 빈 문자열이 들어오면 처리한다.") + void blankEmail(String email) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(email, "ORGANIZTION", "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } + + @ParameterizedTest + @CsvSource(value = {"email:true", "email@email:false", "email@email.com:false"}, delimiter = ':') + @DisplayName("oauth 회원가입 이메일에 옳지 않은 이메일 형식의 문자열이 들어오면 처리한다.") + void invalidEmail(String email, boolean flag) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(email, "ORGANIZTION", "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMAIL_MESSAGE))) + .isEqualTo(flag); + } + + @ParameterizedTest + @NullSource + @DisplayName("회원가입 조직명에 빈 문자열이 들어오면 처리한다.") + void blankOrganization(String organization) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest("email@email.com", organization, "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } + + @ParameterizedTest + @CsvSource(value = {"hihellomorethantwenty:true", "한글조직:false", "hihello:false", "안 녕 하 세 요:false", "ㄱㄴ 힣 ㄷㄹ:false"}, delimiter = ':') + @DisplayName("회원가입 조직명에 옳지 않은 형식의 문자열이 들어오면 처리한다.") + void invalidOrganization(String organization, boolean flag) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest("email@email.com", organization, "GOOGLE"); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(ORGANIZATION_MESSAGE))) + .isEqualTo(flag); + } + + @ParameterizedTest + @NullSource + @DisplayName("회원가입한 oauth 제공사에 빈 문자열이 들어오면 처리한다.") + void blankOauthProvider(String oauthProvider) { + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest("email@email.com", "organization", oauthProvider); + + assertThat(getConstraintViolations(oauthMemberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java new file mode 100644 index 000000000..2e9fada7e --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java @@ -0,0 +1,97 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.Constants; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class GithubRequesterTest { + + private static final String ACCESS_TOKEN_RESPONSE_EXAMPLE = "{\n" + + " \"access_token\": \"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\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"scope\": \"user:email\"\n" + + "}"; + + private static final String USER_INFO_RESPONSE_EXAMPLE = "{\n" + + " \"login\": \"pobi\",\n" + + " \"id\": 1,\n" + + " \"node_id\": \"MDQ6VXNlcjQ5MzQ2Njc3\",\n" + + " \"avatar_url\": \"https://avatars.githubusercontent.com/u/49346677?v=4\",\n" + + " \"gravatar_id\": \"\",\n" + + " \"url\": \"https://api.github.com/users/tributetothemoon\",\n" + + " \"html_url\": \"https://github.com/tributetothemoon\",\n" + + " \"followers_url\": \"https://api.github.com/users/tributetothemoon/followers\",\n" + + " \"following_url\": \"https://api.github.com/users/tributetothemoon/following{/other_user}\",\n" + + " \"gists_url\": \"https://api.github.com/users/tributetothemoon/gists{/gist_id}\",\n" + + " \"starred_url\": \"https://api.github.com/users/tributetothemoon/starred{/owner}{/repo}\",\n" + + " \"subscriptions_url\": \"https://api.github.com/users/tributetothemoon/subscriptions\",\n" + + " \"organizations_url\": \"https://api.github.com/users/tributetothemoon/orgs\",\n" + + " \"repos_url\": \"https://api.github.com/users/tributetothemoon/repos\",\n" + + " \"events_url\": \"https://api.github.com/users/tributetothemoon/events{/privacy}\",\n" + + " \"received_events_url\": \"https://api.github.com/users/tributetothemoon/received_events\",\n" + + " \"type\": \"User\",\n" + + " \"site_admin\": false,\n" + + " \"name\": \"Jaesung Park\",\n" + + " \"company\": \"@woowacourse\",\n" + + " \"blog\": \"woowacourse.github.io\",\n" + + " \"location\": \"Seoul, Korea\",\n" + + " \"email\": \"pobi@email.com\",\n" + + " \"hireable\": null,\n" + + " \"bio\": null,\n" + + " \"twitter_username\": null,\n" + + " \"public_repos\": 27,\n" + + " \"public_gists\": 0,\n" + + " \"followers\": 300000,\n" + + " \"following\": 1,\n" + + " \"created_at\": \"2019-04-06T16:39:37Z\",\n" + + " \"updated_at\": \"2021-09-05T07:23:40Z\"\n" + + "}"; + + @Test + @DisplayName("서버에 요청을 보내 코드로부터 유저의 정보를 가져온다.") + void getUserInfoByCode() { + try (MockWebServer mockGithubServer = new MockWebServer()) { + // given + mockGithubServer.start(); + + setUpResponse(mockGithubServer); + + GithubRequester githubRequester = new GithubRequester( + "clientId", + "secretId", + String.format("http://%s:%s", mockGithubServer.getHostName(), mockGithubServer.getPort()), + String.format("http://%s:%s", mockGithubServer.getHostName(), mockGithubServer.getPort()) + ); + + // when + OauthUserInfo code = githubRequester.getUserInfoByCode("code"); + String email = code.getEmail(); + + // then + assertThat(email).isEqualTo(Constants.EMAIL); + mockGithubServer.shutdown(); + } catch (IOException ignored) { + } + } + + private void setUpResponse(MockWebServer mockGithubServer) { + mockGithubServer.enqueue(new MockResponse() + .setBody(ACCESS_TOKEN_RESPONSE_EXAMPLE) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + + mockGithubServer.enqueue(new MockResponse() + .setBody(USER_INFO_RESPONSE_EXAMPLE) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java new file mode 100644 index 000000000..8a11af175 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java @@ -0,0 +1,92 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +class GoogleRequesterTest { + public static final String SALLY_EMAIL = "dusdn1702@gmail.com"; + private static final String GOOGLE_TOKEN_RESPONSE = "{\n" + + " \"access_token\": \"ACCESS_TOKEN_AT_HERE\",\n" + + " \"expires_in\": \"3599\",\n" + + " \"refresh_token\": \"REFRESH_TOKEN_AT_HERE\",\n" + + " \"scope\": \"https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/indexing openid https://www.googleapis.com/auth/userinfo.email\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"id_token\": \"ID_TOKEN_AT_HERE\"\n" + + "}"; + + private static final String USER_INFO_RESPONSE_EXAMPLE = "{\n" + + " \"id\": \"107677594285931275665\",\n" + + " \"email\": \"" + SALLY_EMAIL + "\",\n" + + " \"verified_email\": true,\n" + + " \"name\": \"Yeonwoo Cho\",\n" + + " \"given_name\": \"Yeonwoo\",\n" + + " \"family_name\": \"Cho\",\n" + + " \"picture\": \"https://lh3.googleusercontent.com/a/AATXAJyyWzN0hxXPXy_hoPNk7ww9Kuu990o-ImGrdPe9=s96-c\",\n" + + " \"locale\": \"ko\"\n" + + "}"; + + @Test + @DisplayName("서버에 요청을 보내 코드로부터 유저의 정보를 가져온다.") + void getUserInfoByCode() { + try (MockWebServer mockGoogleServer = new MockWebServer()) { + // given + mockGoogleServer.start(); + + setUpResponse(mockGoogleServer); + + GoogleRequester googleRequester = new GoogleRequester( + "clientId", + "secretId", + "redirectUri", + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()), + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()) + ); + + // when + OauthUserInfo code = googleRequester.getUserInfoByCode("code"); + String email = code.getEmail(); + + //then + assertThat(email).isEqualTo(SALLY_EMAIL); + mockGoogleServer.shutdown(); + } catch (IOException ignored) { + } + } + + @Test + @DisplayName("들어온 oauth 제공자가 자신이면 true를 반환한다.") + void supports() { + GoogleRequester googleRequester = new GoogleRequester( + "clientId", + "secretId", + "redirectUri", + "baseLoginUri", + "baseUserUri" + ); + + assertThat(googleRequester.supports(OauthProvider.GOOGLE)).isTrue(); + assertThat(googleRequester.supports(OauthProvider.GITHUB)).isFalse(); + } + + private void setUpResponse(MockWebServer mockGithubServer) { + mockGithubServer.enqueue(new MockResponse() + .setBody(GOOGLE_TOKEN_RESPONSE) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + + mockGithubServer.enqueue(new MockResponse() + .setBody(USER_INFO_RESPONSE_EXAMPLE) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java new file mode 100644 index 000000000..0aa406970 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java @@ -0,0 +1,66 @@ +package com.woowacourse.zzimkkong.infrastructure.oauth; + +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static com.woowacourse.zzimkkong.infrastructure.oauth.GoogleRequesterTest.SALLY_EMAIL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +@ActiveProfiles("test") +class OauthHandlerTest { + @Autowired + private OauthHandler oauthHandler; + + @MockBean + private GoogleRequester googleRequester; + + @MockBean + private GithubRequester githubRequester; + + @ParameterizedTest + @DisplayName("Oauth 제공사에 따라 적당한 OauthRequester를 찾아 code로부터 유저 정보를 가져온다.") + @EnumSource(OauthProvider.class) + void getUserInfoFromCodeWithGithub(OauthProvider oauthProvider) { + //given + given(githubRequester.supports(OauthProvider.GITHUB)) + .willReturn(true); + mockingGithubGetUserInfo(SALLY_EMAIL); + + given(googleRequester.supports(OauthProvider.GOOGLE)) + .willReturn(true); + mockingGoogleGetUserInfo(SALLY_EMAIL); + + //when + OauthUserInfo oauthUserInfo = oauthHandler.getUserInfoFromCode(oauthProvider, "code"); + String email = oauthUserInfo.getEmail(); + + //then + assertThat(email).isEqualTo(SALLY_EMAIL); + } + + private void mockingGithubGetUserInfo(String email) { + given(githubRequester.getUserInfoByCode(anyString())) + .willReturn(GithubUserInfo.from(Map.of("email", email))); + } + + private void mockingGoogleGetUserInfo(String email) { + given(googleRequester.getUserInfoByCode(anyString())) + .willReturn(GoogleUserInfo.from( + Map.of("id", "12", + "email", email))); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java index 473a554e9..b77356253 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java @@ -1,22 +1,33 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; +import com.woowacourse.zzimkkong.infrastructure.JwtUtils; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import java.util.Arrays; import java.util.Optional; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; class AuthServiceTest extends ServiceTest { private Member pobi; @@ -26,9 +37,15 @@ void setUp() { pobi = new Member(EMAIL, passwordEncoder.encode(PW), ORGANIZATION); } + @MockBean + private OauthHandler oauthHandler; + @Autowired private AuthService authService; + @Autowired + private JwtUtils jwtUtils; + @Test @DisplayName("회원 로그인 요청이 옳다면 토큰을 발급한다.") void login() { @@ -74,4 +91,72 @@ void loginMismatchException() { assertThatThrownBy(() -> authService.login(loginRequest)) .isInstanceOf(PasswordMismatchException.class); } + + @ParameterizedTest + @EnumSource(OauthProvider.class) + @DisplayName("Oauth 인증 코드를 통해 토큰을 발급한다.") + void loginByOauth(OauthProvider oauthProvider) { + // given + String mockCode = "Mock Code from OauthProvider"; + + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.findByEmail(EMAIL)) + .willReturn(Optional.of(new Member(EMAIL, ORGANIZATION, oauthProvider))); + + // when + TokenResponse tokenResponse = authService.loginByOauth(oauthProvider, mockCode); + + // then + String accessToken = tokenResponse.getAccessToken(); + assertThat(accessToken).isNotNull(); + jwtUtils.validateToken(accessToken); + } + + @ParameterizedTest + @EnumSource(OauthProvider.class) + @DisplayName("존재하지 않는 이메일로 oauth 로그인 시 오류가 발생한다.") + void loginByOauthInvalidEmailException(OauthProvider oauthProvider) { + // given + String mockCode = "Mock Code from OauthProvider"; + + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.findByEmail(EMAIL)) + .willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> authService.loginByOauth(oauthProvider, mockCode)) + .isInstanceOf(NoSuchMemberException.class); + } + + @ParameterizedTest + @EnumSource(OauthProvider.class) + @DisplayName("회원가입한 provider와 다른 provider로 같은 이메일 oauth 로그인 시 오류가 발생한다.") + void loginByOauthInvalidProviderException(OauthProvider oauthProvider) { + // given + String mockCode = "Mock Code from OauthProvider"; + OauthProvider anotherProvider = Arrays.stream(OauthProvider.values()) + .filter(provider -> !provider.equals(oauthProvider)) + .findAny() + .get(); + + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.findByEmail(EMAIL)) + .willReturn(Optional.of(new Member(EMAIL, ORGANIZATION, anotherProvider))); + + // when, then + assertThatThrownBy(() -> authService.loginByOauth(oauthProvider, mockCode)) + .isInstanceOf(OauthProviderMismatchException.class); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java index 8f54e37f5..1dc0f2152 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java @@ -1,12 +1,21 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.OauthProvider; +import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; +import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; @@ -14,11 +23,15 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; class MemberServiceTest extends ServiceTest { @Autowired private MemberService memberService; + @MockBean + private OauthHandler oauthHandler; + @Test @DisplayName("회원이 올바르게 저장을 요청하면 저장한다.") void saveMember() { @@ -60,4 +73,90 @@ void saveMemberException() { assertThatThrownBy(() -> memberService.saveMember(memberSaveRequest)) .isInstanceOf(DuplicateEmailException.class); } + + @ParameterizedTest + @EnumSource(OauthProvider.class) + @DisplayName("Oauth를 통해 얻을 수 없는 정보를 응답하며 회원가입 과정을 진행한다.") + void getUserInfoFromOauth(OauthProvider oauthProvider) { + //given + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.existsByEmail(EMAIL)) + .willReturn(false); + + //when + OauthReadyResponse actual = memberService.getUserInfoFromOauth(oauthProvider, "code-example"); + OauthReadyResponse expected = OauthReadyResponse.of(EMAIL, oauthProvider); + + //then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @ParameterizedTest + @EnumSource(OauthProvider.class) + @DisplayName("이미 존재하는 이메일로 oauth 정보를 가져오면 에러가 발생한다.") + void getUserInfoFromOauthException(OauthProvider oauthProvider) { + //given + OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); + given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) + .willReturn(mockOauthUserInfo); + given(mockOauthUserInfo.getEmail()) + .willReturn(EMAIL); + given(members.existsByEmail(EMAIL)) + .willReturn(true); + + //when, then + assertThatThrownBy(() -> memberService.getUserInfoFromOauth(oauthProvider, "code-example")) + .isInstanceOf(DuplicateEmailException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"GOOGLE", "GITHUB"}) + @DisplayName("소셜 로그인을 이용해 회원가입한다.") + void saveMemberByOauth(String oauth) { + //given + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(EMAIL, ORGANIZATION, oauth); + Member member = new Member( + oauthMemberSaveRequest.getEmail(), + oauthMemberSaveRequest.getOrganization(), + oauthMemberSaveRequest.getOauthProvider() + ); + given(members.existsByEmail(anyString())) + .willReturn(false); + + //when + Member savedMember = new Member( + 1L, + member.getEmail(), + member.getOrganization(), + member.getOauthProvider()); + given(members.save(any(Member.class))) + .willReturn(savedMember); + + //then + MemberSaveResponse memberSaveResponse = MemberSaveResponse.from(savedMember); + assertThat(memberService.saveMemberByOauth(oauthMemberSaveRequest)).usingRecursiveComparison() + .isEqualTo(memberSaveResponse); + } + + + @ParameterizedTest + @ValueSource(strings = {"GOOGLE", "GITHUB"}) + @DisplayName("이미 존재하는 이메일로 소셜 로그인을 이용해 회원가입하면 에러가 발생한다.") + void saveMemberByOauthException(String oauth) { + //given + OauthMemberSaveRequest oauthMemberSaveRequest = new OauthMemberSaveRequest(EMAIL, ORGANIZATION, oauth); + + //when + given(members.existsByEmail(anyString())) + .willReturn(true); + + //then + assertThatThrownBy(() -> memberService.saveMemberByOauth(oauthMemberSaveRequest)) + .isInstanceOf(DuplicateEmailException.class); + } } From f02d00d0c3a660e4acd6ef7eb0dbef5e2bec1673 Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Tue, 14 Sep 2021 14:24:50 +0900 Subject: [PATCH 06/25] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20UI=20=EA=B5=AC=ED=98=84=20(#53?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Storybook에 `MemoryRouter` 데코레이터 추가 * feat: `SocialLoginButton` 컴포넌트 정의 * feat: 로그인 페이지에서 소셜 로그인 버튼 UI 배치 --- frontend/.storybook/preview.js | 6 ++++ frontend/src/assets/svg/github-logo.svg | 1 + frontend/src/assets/svg/google-logo.svg | 1 + .../SocialLoginButton.stories.tsx | 26 ++++++++++++++ .../SocialLoginButton.styles.ts | 35 +++++++++++++++++++ .../SocialLoginButton/SocialLoginButton.tsx | 30 ++++++++++++++++ frontend/src/constants/palette.ts | 2 ++ .../pages/ManagerLogin/ManagerLogin.styles.ts | 11 ++++++ .../src/pages/ManagerLogin/ManagerLogin.tsx | 7 ++++ 9 files changed, 119 insertions(+) create mode 100644 frontend/src/assets/svg/github-logo.svg create mode 100644 frontend/src/assets/svg/google-logo.svg create mode 100644 frontend/src/components/SocialLoginButton/SocialLoginButton.stories.tsx create mode 100644 frontend/src/components/SocialLoginButton/SocialLoginButton.styles.ts create mode 100644 frontend/src/components/SocialLoginButton/SocialLoginButton.tsx diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index 380836518..b7fc849a7 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -1,6 +1,7 @@ import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; import { ThemeProvider } from 'styled-components'; import { theme, GlobalStyle } from '../src/App.styles'; +import { MemoryRouter } from 'react-router'; export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, @@ -23,4 +24,9 @@ export const decorators = [ ), + (Story) => ( + + + + ), ]; diff --git a/frontend/src/assets/svg/github-logo.svg b/frontend/src/assets/svg/github-logo.svg new file mode 100644 index 000000000..4f0031f56 --- /dev/null +++ b/frontend/src/assets/svg/github-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/svg/google-logo.svg b/frontend/src/assets/svg/google-logo.svg new file mode 100644 index 000000000..55521b9fb --- /dev/null +++ b/frontend/src/assets/svg/google-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/SocialLoginButton/SocialLoginButton.stories.tsx b/frontend/src/components/SocialLoginButton/SocialLoginButton.stories.tsx new file mode 100644 index 000000000..566034d3f --- /dev/null +++ b/frontend/src/components/SocialLoginButton/SocialLoginButton.stories.tsx @@ -0,0 +1,26 @@ +import { Story } from '@storybook/react'; +import { PropsWithChildren } from 'react'; +import SocialLoginButton, { Props } from './SocialLoginButton'; + +export default { + title: 'shared/SocialLoginButton', + component: SocialLoginButton, + argTypes: { + provider: { + options: ['github', 'google'], + control: { type: 'radio' }, + }, + }, +}; + +const Template: Story> = (args) => ; + +export const Github = Template.bind({}); +Github.args = { + provider: 'github', +}; + +export const Google = Template.bind({}); +Google.args = { + provider: 'google', +}; diff --git a/frontend/src/components/SocialLoginButton/SocialLoginButton.styles.ts b/frontend/src/components/SocialLoginButton/SocialLoginButton.styles.ts new file mode 100644 index 000000000..4c29cd096 --- /dev/null +++ b/frontend/src/components/SocialLoginButton/SocialLoginButton.styles.ts @@ -0,0 +1,35 @@ +import styled, { css } from 'styled-components'; +import PALETTE from 'constants/palette'; +import { Props } from './SocialLoginButton'; + +const providerCSS = { + github: css` + background-color: ${PALETTE.GITHUB}; + color: ${PALETTE.WHITE}; + border: none; + `, + google: css` + background-color: ${PALETTE.WHITE}; + color: ${PALETTE.BLACK[700]}; + border: 1px solid ${PALETTE.BLACK[700]}; + `, +}; + +export const SocialLoginButton = styled.a` + ${({ provider }) => providerCSS[provider]}; + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + text-decoration: none; + padding: 0.75rem 1rem; + font-size: 1.25rem; +`; + +export const Icon = styled.div` + display: inline-flex; + align-items: center; + justify-content: flex-end; +`; + +export const Text = styled.div``; diff --git a/frontend/src/components/SocialLoginButton/SocialLoginButton.tsx b/frontend/src/components/SocialLoginButton/SocialLoginButton.tsx new file mode 100644 index 000000000..86ebe923d --- /dev/null +++ b/frontend/src/components/SocialLoginButton/SocialLoginButton.tsx @@ -0,0 +1,30 @@ +import { AnchorHTMLAttributes } from 'react'; +import { ReactComponent as GithubIcon } from 'assets/svg/github-logo.svg'; +import { ReactComponent as GoogleIcon } from 'assets/svg/google-logo.svg'; +import * as Styled from './SocialLoginButton.styles'; + +export interface Props extends AnchorHTMLAttributes { + provider: 'github' | 'google'; +} + +const social = { + github: { + icon: , + text: 'Github로 로그인', + }, + google: { + icon: , + text: 'Google로 로그인', + }, +}; + +const SocialLoginButton = ({ provider, ...props }: Props): JSX.Element => { + return ( + + {social[provider].icon} + {social[provider].text} + + ); +}; + +export default SocialLoginButton; diff --git a/frontend/src/constants/palette.ts b/frontend/src/constants/palette.ts index 248e5c94a..2e6d0439f 100644 --- a/frontend/src/constants/palette.ts +++ b/frontend/src/constants/palette.ts @@ -106,6 +106,8 @@ const PALETTE = { 900: 'rgba(0, 0, 0, 0.9)', }, WHITE: '#fff', + + GITHUB: '#24292F', }; export default PALETTE; diff --git a/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts b/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts index b319e03d9..12938e0c8 100644 --- a/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts +++ b/frontend/src/pages/ManagerLogin/ManagerLogin.styles.ts @@ -28,3 +28,14 @@ export const JoinLinkMessage = styled.p` } } `; + +export const HorizontalLine = styled.hr` + margin: 1.5rem 0; +`; + +export const SocialLogin = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + margin: 1.5rem 0; +`; diff --git a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx index d41eb8930..01b14ac67 100644 --- a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx +++ b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx @@ -7,6 +7,7 @@ import { useSetRecoilState } from 'recoil'; import { postLogin } from 'api/login'; import Header from 'components/Header/Header'; import Layout from 'components/Layout/Layout'; +import SocialLoginButton from 'components/SocialLoginButton/SocialLoginButton'; import MESSAGE from 'constants/message'; import PATH from 'constants/path'; import { LOCAL_STORAGE_KEY } from 'constants/storage'; @@ -28,6 +29,7 @@ export interface LoginParams { const ManagerLogin = (): JSX.Element => { const history = useHistory(); + const setAccessToken = useSetRecoilState(accessTokenState); const [errorMessage, setErrorMessage] = useState({ @@ -70,6 +72,11 @@ const ManagerLogin = (): JSX.Element => { 로그인 + + + + + 아직 회원이 아니신가요? 회원가입하기 From 50fa6517c69452ba447199cd070ab1419ec8e2d3 Mon Sep 17 00:00:00 2001 From: Kimun Kim Date: Tue, 14 Sep 2021 14:50:55 +0900 Subject: [PATCH 07/25] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EC=9D=80=20?= =?UTF-8?q?=EC=9E=90=EC=8B=A0=EC=9D=98=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C,=20=EC=88=98=EC=A0=95,=20=EC=82=AD=EC=A0=9C(?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4)=ED=95=A0=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=88=EB=8B=A4.=20(#530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유저의 자기 정보 조회 기능 구현 * feat: 유저의 자기 정보 수정 기능 구현 * feat: MemberRepository에서 Member의 Id로 예약이 존재하는지 확인하는 기능 생성 * feat: 계정 삭제시 맵 삭제를 위한 Member-Map 간 양방향 매핑 추가 * feat: 회원 탈퇴 기능 구현 * feat: DataLoader 현재 스키마 조건과 안 맞는 부분 수정 - pobi 계정에 대해 'test1234'를 암호화한 값을 세팅합니다. - enabledDayOfWeek에 모든 요일이 가능하도록 세팅합니다. * docs: 유저 정보 조회/수정/삭제(회원 탈퇴) API 문서화 * fix: MemberRepository 회원 탈퇴 테스트 실패하는 문제 해결 * fix: existsReservationsByMember Map의 id를 where 절로 거는 오류 해결 * refactor: 일부 메소드의 파라미터 final 속성 부여 * refactor: 유저가 소유한 공간에 예약이 있는지 확인하는 쿼리를 Member -> Reservation Repo로 이동 * fix: Jackson 라이브러리 문제로 InputFieldErrorResponse가 Mapping 되지 않는 문제 해결 * refactor: MemberFindResponse 생성자 접근 제어자 private 속성 부여 * style: MemberServiceTest DisplayName 수정 * style: MemberControllerTest 코드 포맷팅 * feat: Map 생성시 Member 객체에 생성된 Map에 대한 참조값 추가 * fix: 멤버가 가진 공간의 예약 존재 여부 조회시 과거 내역까지 산정하는 문제 해결 * style: 코드 포매팅 * test: 양방향 매핑으로 인해 Map에 추가된 편의메서드에 대한 분기 테스트 작성 --- backend/src/docs/asciidoc/member.adoc | 18 ++ .../com/woowacourse/zzimkkong/DataLoader.java | 228 ++++++++++++++++++ .../controller/MemberController.java | 20 ++ .../com/woowacourse/zzimkkong/domain/Map.java | 4 + .../woowacourse/zzimkkong/domain/Member.java | 17 +- .../dto/InputFieldErrorResponse.java | 4 +- .../dto/member/MemberFindResponse.java | 26 ++ .../dto/member/MemberUpdateRequest.java | 21 ++ .../ReservationExistsOnMemberException.java | 12 + .../repository/ReservationRepository.java | 2 +- .../ReservationRepositoryCustom.java | 7 + .../repository/ReservationRepositoryImpl.java | 25 ++ .../zzimkkong/service/MemberService.java | 19 ++ .../controller/MemberControllerTest.java | 82 +++++++ .../woowacourse/zzimkkong/domain/MapTest.java | 18 ++ .../dto/MemberUpdateRequestTest.java | 22 ++ .../repository/MemberRepositoryTest.java | 4 +- .../ReservationRepositoryImplTest.java | 76 ++++++ .../repository/ReservationRepositoryTest.java | 5 +- .../zzimkkong/service/MemberServiceTest.java | 48 +++- 20 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberFindResponse.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberUpdateRequest.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/member/ReservationExistsOnMemberException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryCustom.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImpl.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberUpdateRequestTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImplTest.java diff --git a/backend/src/docs/asciidoc/member.adoc b/backend/src/docs/asciidoc/member.adoc index 02c34b6ae..0520b584a 100644 --- a/backend/src/docs/asciidoc/member.adoc +++ b/backend/src/docs/asciidoc/member.adoc @@ -61,3 +61,21 @@ include::{snippets}/member/login/oauth/GOOGLE/http-response.adoc[] include::{snippets}/member/login/oauth/GITHUB/http-request.adoc[] ==== Response include::{snippets}/member/login/oauth/GITHUB/http-response.adoc[] + +=== 멤버 정보 조회 +==== Request +include::{snippets}/member/myinfo/get/http-request.adoc[] +==== Response +include::{snippets}/member/myinfo/get/http-response.adoc[] + +=== 멤버 정보 수정 +==== Request +include::{snippets}/member/myinfo/put/http-request.adoc[] +==== Response +include::{snippets}/member/myinfo/put/http-response.adoc[] + +=== 회원 탈퇴 +==== Request +include::{snippets}/member/myinfo/delete/http-request.adoc[] +==== Response +include::{snippets}/member/myinfo/delete/http-response.adoc[] diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java new file mode 100644 index 000000000..ef8afd74a --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java @@ -0,0 +1,228 @@ +package com.woowacourse.zzimkkong; + +import com.woowacourse.zzimkkong.domain.*; +import com.woowacourse.zzimkkong.repository.MapRepository; +import com.woowacourse.zzimkkong.repository.MemberRepository; +import com.woowacourse.zzimkkong.repository.ReservationRepository; +import com.woowacourse.zzimkkong.repository.SpaceRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@Component +@Profile({"local"}) +public class DataLoader implements CommandLineRunner { + private final MemberRepository members; + private final MapRepository maps; + private final SpaceRepository spaces; + private final ReservationRepository reservations; + + public DataLoader( + final MemberRepository memberRepository, + final MapRepository mapRepository, + final SpaceRepository spaceRepository, + final ReservationRepository reservationRepository) { + this.members = memberRepository; + this.maps = mapRepository; + this.spaces = spaceRepository; + this.reservations = reservationRepository; + } + + @Override + public void run(String... args) { + String lectureRoomColor = "#FED7D9"; + String pairRoomColor = "#CCDFFB"; + String meetingRoomColor = "#FFE3AC"; + + Member pobi = members.save( + new Member("pobi@woowa.com", + "$2a$10$c3BysogWR4hnexYx60/r/e3lEUIbSs4zhW6kuX4UW733MW5/NmbW.", // test1234 입니다. + "woowacourse") + ); + + Map luther = maps.save( + new Map( + "루터회관", + "{'id': '1', 'type': 'polyline', 'fill': '', 'stroke': 'rgba(111, 111, 111, 1)', 'points': '['60,250', '1,231', '242,252']', 'd': '[]', 'transform': ''}", + "https://d1dgzmdd5f1fx6.cloudfront.net/thumbnails/1.png", + pobi) + ); + + Setting defaultSetting = Setting.builder() + .availableStartTime(LocalTime.of(0, 0)) + .availableEndTime(LocalTime.of(23, 59)) + .reservationTimeUnit(10) + .reservationMinimumTimeUnit(10) + .reservationMaximumTimeUnit(1440) + .reservationEnable(true) + .enabledDayOfWeek("monday,tuesday,wednesday,thursday,friday,saturday,sunday") + .build(); + + Space be = Space.builder() + .name("백엔드 강의실") + .color(lectureRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space fe1 = Space.builder() + .name("프론트엔드 강의실1") + .color(lectureRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space fe2 = Space.builder() + .name("프론트엔드 강의실2") + .color(lectureRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space meetingRoom1 = Space.builder() + .name("회의실1") + .color(meetingRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space meetingRoom2 = Space.builder() + .name("회의실2") + .color(meetingRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space meetingRoom3 = Space.builder() + .name("회의실3") + .color(meetingRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space meetingRoom4 = Space.builder() + .name("회의실4") + .color(meetingRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space meetingRoom5 = Space.builder() + .name("회의실5") + .color(meetingRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space pairRoom1 = Space.builder() + .name("페어룸1") + .color(pairRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space pairRoom2 = Space.builder() + .name("페어룸2") + .color(pairRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space pairRoom3 = Space.builder() + .name("페어룸3") + .color(pairRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space pairRoom4 = Space.builder() + .name("페어룸4") + .color(pairRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space pairRoom5 = Space.builder() + .name("페어룸5") + .color(pairRoomColor) + .map(luther) + .setting(defaultSetting) + .build(); + + Space trackRoom = Space.builder() + .name("트랙방") + .color("#D8FBCC") + .map(luther) + .setting(defaultSetting) + .build(); + + List sampleSpaces = List.of( + be, + fe1, fe2, + meetingRoom1, meetingRoom2, meetingRoom3, meetingRoom4, meetingRoom5, + pairRoom1, pairRoom2, pairRoom3, pairRoom4, pairRoom5, + trackRoom + ); + + for (Space space : sampleSpaces) { + spaces.save(space); + } + + LocalDate targetDate = LocalDate.now().plusDays(1L); + + Reservation reservationBackEndTargetDate0To1 = Reservation.builder() + .startTime(targetDate.atStartOfDay()) + .endTime(targetDate.atTime(1, 0, 0)) + .description("찜꽁 1차 회의") + .userName("찜꽁") + .password("1234") + .space(be) + .build(); + + Reservation reservationBackEndTargetDate13To14 = Reservation.builder() + .startTime(targetDate.atTime(13, 0, 0)) + .endTime(targetDate.atTime(14, 0, 0)) + .description("찜꽁 2차 회의") + .userName("찜꽁") + .password("1234") + .space(be) + .build(); + + Reservation reservationBackEndTargetDate18To23 = Reservation.builder() + .startTime(targetDate.atTime(18, 0, 0)) + .endTime(targetDate.atTime(23, 59, 59)) + .description("찜꽁 3차 회의") + .userName("찜꽁") + .password("6789") + .space(be) + .build(); + + Reservation reservationBackEndTheDayAfterTargetDate = Reservation.builder() + .startTime(targetDate.plusDays(1L).atStartOfDay()) + .endTime(targetDate.plusDays(1L).atTime(1, 0, 0)) + .description("찜꽁 4차 회의") + .userName("찜꽁") + .password("1234") + .space(be) + .build(); + + Reservation reservationFrontEnd1TargetDate0to1 = Reservation.builder() + .startTime(targetDate.atStartOfDay()) + .endTime(targetDate.atTime(1, 0, 0)) + .description("찜꽁 5차 회의") + .userName("찜꽁") + .password("1234") + .space(fe1) + .build(); + + reservations.save(reservationBackEndTargetDate0To1); + reservations.save(reservationBackEndTargetDate13To14); + reservations.save(reservationBackEndTargetDate18To23); + reservations.save(reservationBackEndTheDayAfterTargetDate); + reservations.save(reservationFrontEnd1TargetDate0to1); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java index c6dc58590..03449acda 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java @@ -89,4 +89,24 @@ public ResponseEntity deletePreset( return ResponseEntity.noContent().build(); } + + @GetMapping("/me") + public ResponseEntity findMember(@Manager final Member manager) { + MemberFindResponse memberFindResponse = MemberFindResponse.from(manager); + return ResponseEntity.ok().body(memberFindResponse); + } + + @PutMapping("/me") + public ResponseEntity updateMember( + @Manager final Member manager, + @RequestBody @Valid final MemberUpdateRequest memberUpdateRequest) { + memberService.updateMember(manager, memberUpdateRequest); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/me") + public ResponseEntity deleteMember(@Manager final Member manager) { + memberService.deleteMember(manager); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java index c3b9a9bf5..18e187166 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Map.java @@ -40,6 +40,10 @@ public Map(final String name, final String mapDrawing, final String mapImageUrl, this.mapDrawing = mapDrawing; this.mapImageUrl = mapImageUrl; this.member = member; + + if (member != null) { + member.addMap(this); + } } public Map(final Long id, final String name, final String mapDrawing, final String mapImageUrl, final Member member) { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java index e166ba677..cb9865594 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/domain/Member.java @@ -26,13 +26,16 @@ public class Member { @Column(nullable = false, length = 20) private String organization; - @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List presets = new ArrayList<>(); - @Column(length = 10) @Enumerated(EnumType.STRING) private OauthProvider oauthProvider; + @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List presets = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List maps = new ArrayList<>(); + public Member( final String email, final String password, @@ -68,6 +71,10 @@ public Optional findPresetById(final Long presetId) { .findAny(); } + public void addMap(final Map map) { + this.maps.add(map); + } + public void addPreset(final Preset preset) { this.presets.add(preset); } @@ -75,4 +82,8 @@ public void addPreset(final Preset preset) { public List getPresets() { return Collections.unmodifiableList(presets); } + + public void update(final String organization) { + this.organization = organization; + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java index 20c185ada..219f6fdd5 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/InputFieldErrorResponse.java @@ -2,11 +2,13 @@ import com.woowacourse.zzimkkong.exception.InputFieldException; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.web.bind.MethodArgumentNotValidException; @Getter +@NoArgsConstructor public class InputFieldErrorResponse extends ErrorResponse { - private final String field; + private String field; private InputFieldErrorResponse(final String message, final String field) { super(message); diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberFindResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberFindResponse.java new file mode 100644 index 000000000..ffdb0890c --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberFindResponse.java @@ -0,0 +1,26 @@ +package com.woowacourse.zzimkkong.dto.member; + +import com.woowacourse.zzimkkong.domain.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MemberFindResponse { + private Long id; + private String email; + private String organization; + + private MemberFindResponse(final Long id, final String email, final String organization) { + this.id = id; + this.email = email; + this.organization = organization; + } + + public static MemberFindResponse from(final Member member) { + return new MemberFindResponse( + member.getId(), + member.getEmail(), + member.getOrganization()); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberUpdateRequest.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberUpdateRequest.java new file mode 100644 index 000000000..5cccd90d3 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/MemberUpdateRequest.java @@ -0,0 +1,21 @@ +package com.woowacourse.zzimkkong.dto.member; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.*; + +@Getter +@NoArgsConstructor +public class MemberUpdateRequest { + @NotNull(message = EMPTY_MESSAGE) + @Pattern(regexp = ORGANIZATION_FORMAT, message = ORGANIZATION_MESSAGE) + private String organization; + + public MemberUpdateRequest(final String organization) { + this.organization = organization; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/ReservationExistsOnMemberException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/ReservationExistsOnMemberException.java new file mode 100644 index 000000000..81c27e590 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/ReservationExistsOnMemberException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.member; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class ReservationExistsOnMemberException extends ZzimkkongException { + private static final String MESSAGE = "예약이 존재하는 공간이 있습니다. 사전에 미리 취소해주세요."; + + public ReservationExistsOnMemberException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java index 46bf08e1b..5473c3cae 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java @@ -7,7 +7,7 @@ import java.util.Collection; import java.util.List; -public interface ReservationRepository extends JpaRepository { +public interface ReservationRepository extends JpaRepository, ReservationRepositoryCustom { List findAllBySpaceIdInAndStartTimeIsBetweenAndEndTimeIsBetween( final Collection spaceIds, final LocalDateTime firstStartTime, diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryCustom.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryCustom.java new file mode 100644 index 000000000..41f8f7c49 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryCustom.java @@ -0,0 +1,7 @@ +package com.woowacourse.zzimkkong.repository; + +import com.woowacourse.zzimkkong.domain.Member; + +public interface ReservationRepositoryCustom { + boolean existsReservationsByMemberFromToday(Member member); +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImpl.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImpl.java new file mode 100644 index 000000000..c28c67ffc --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.woowacourse.zzimkkong.repository; + +import com.woowacourse.zzimkkong.domain.Member; + +import javax.persistence.EntityManager; +import java.time.LocalDateTime; + +public class ReservationRepositoryImpl implements ReservationRepositoryCustom { + private final EntityManager entityManager; + + public ReservationRepositoryImpl(final EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public boolean existsReservationsByMemberFromToday(Member member) { + return entityManager.createQuery( + "SELECT COUNT(r) > 0 FROM Reservation r " + + "JOIN r.space s JOIN s.map m " + + "WHERE m.member = :member AND r.endTime >= :currentTime", Boolean.class) + .setParameter("member", member) + .setParameter("currentTime", LocalDateTime.now()) + .getSingleResult(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java index 2b3dd81c9..8ebe0e101 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java @@ -5,11 +5,14 @@ import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; +import com.woowacourse.zzimkkong.dto.member.MemberUpdateRequest; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; +import com.woowacourse.zzimkkong.exception.member.ReservationExistsOnMemberException; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import com.woowacourse.zzimkkong.repository.MemberRepository; +import com.woowacourse.zzimkkong.repository.ReservationRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,13 +21,16 @@ @Transactional public class MemberService { private final MemberRepository members; + private final ReservationRepository reservations; private final PasswordEncoder passwordEncoder; private final OauthHandler oauthHandler; public MemberService(final MemberRepository members, + final ReservationRepository reservations, final PasswordEncoder passwordEncoder, final OauthHandler oauthHandler) { this.members = members; + this.reservations = reservations; this.passwordEncoder = passwordEncoder; this.oauthHandler = oauthHandler; } @@ -70,4 +76,17 @@ public void validateDuplicateEmail(final String email) { throw new DuplicateEmailException(); } } + + public void updateMember(final Member member, final MemberUpdateRequest memberUpdateRequest) { + member.update(memberUpdateRequest.getOrganization()); + } + + public void deleteMember(final Member manager) { + boolean hasAnyReservations = reservations.existsReservationsByMemberFromToday(manager); + if (hasAnyReservations) { + throw new ReservationExistsOnMemberException(); + } + + members.delete(manager); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java index 676d86cd8..89a8bbadf 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java @@ -9,6 +9,9 @@ import com.woowacourse.zzimkkong.dto.member.*; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; +import com.woowacourse.zzimkkong.dto.ErrorResponse; +import com.woowacourse.zzimkkong.dto.InputFieldErrorResponse; +import com.woowacourse.zzimkkong.dto.member.*; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.infrastructure.AuthorizationExtractor; import io.restassured.RestAssured; @@ -197,6 +200,51 @@ void delete() { assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); } + @Test + @DisplayName("유저는 자신의 정보를 조회할 수 있다.") + void findMe() { + // given, when + ExtractableResponse response = findMyInfo(); + + MemberFindResponse actual = response.as(MemberFindResponse.class); + MemberFindResponse expected = MemberFindResponse.from(pobi); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(actual).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(expected); + } + + @Test + @DisplayName("유저는 자신의 정보를 수정할 수 있다.") + void updateMe() { + // given + MemberUpdateRequest memberUpdateRequest = new MemberUpdateRequest("woowabros"); + + // when + ExtractableResponse response = updateMyInfo(memberUpdateRequest); + + // then + MemberFindResponse afterUpdate = findMyInfo().as(MemberFindResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(afterUpdate.getOrganization()).isEqualTo("woowabros"); + } + + @Test + @DisplayName("유저는 회원 탈퇴할 수 있다.") + void deleteMe() { + // given, when + ExtractableResponse response = deleteMyInfo(); + ExtractableResponse errorExpectedResponse = findMyInfo(); + ErrorResponse errorResponse = errorExpectedResponse.as(InputFieldErrorResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + assertThat(errorExpectedResponse.statusCode()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(errorResponse.getMessage()).isNotEmpty(); + } + static ExtractableResponse saveMember(final MemberSaveRequest memberSaveRequest) { return RestAssured .given(getRequestSpecification()).log().all() @@ -273,4 +321,38 @@ private ExtractableResponse deletePreset(String api) { .when().delete(api) .then().log().all().extract(); } + + private ExtractableResponse findMyInfo() { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) + .filter(document("member/myinfo/get", getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/api/managers/me") + .then().log().all().extract(); + } + + private ExtractableResponse updateMyInfo(MemberUpdateRequest memberUpdateRequest) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) + .filter(document("member/myinfo/put", getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(memberUpdateRequest) + .when().put("/api/managers/me") + .then().log().all().extract(); + } + + private ExtractableResponse deleteMyInfo() { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + accessToken) + .filter(document("member/myinfo/delete", getRequestPreprocessor(), getResponsePreprocessor())) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().delete("/api/managers/me") + .then().log().all().extract(); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java index eebb7135b..5bea290f4 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/domain/MapTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; @@ -29,4 +31,20 @@ void isNotOwnedBy() { boolean result = luther.isNotOwnedBy(new Member("삭정이", "test1234", "잠실")); assertThat(result).isTrue(); } + + @ParameterizedTest + @DisplayName("생성자 인자에 주어지는 Member가 null이 아니라면 Member의 maps에 Map이 추가된다.") + @CsvSource({"true", "false"}) + void addMap(boolean nullable) { + Map luther; + if (nullable) { + luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, null); + assertThat(luther.getMember()).isNull(); + return; + } + Member pobi = new Member(EMAIL, PW, ORGANIZATION); + luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, pobi); + + assertThat(pobi.getMaps()).contains(luther); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberUpdateRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberUpdateRequestTest.java new file mode 100644 index 000000000..ddf59161a --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberUpdateRequestTest.java @@ -0,0 +1,22 @@ +package com.woowacourse.zzimkkong.dto; + +import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; + +import static com.woowacourse.zzimkkong.dto.ValidatorMessage.EMPTY_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; + +class MemberUpdateRequestTest extends RequestTest { + @ParameterizedTest + @NullSource + @DisplayName("회원 정보 수정 조직명에 빈 문자열이 들어오면 처리한다.") + void blankOrganization(String organization) { + MemberSaveRequest memberSaveRequest = new MemberSaveRequest("email@email.com", "password", organization); + + assertThat(getConstraintViolations(memberSaveRequest).stream() + .anyMatch(violation -> violation.getMessage().equals(EMPTY_MESSAGE))) + .isTrue(); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java index 92fa28488..92c2eb7e2 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java @@ -1,10 +1,12 @@ package com.woowacourse.zzimkkong.repository; -import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.*; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.dao.DataAccessException; import static com.woowacourse.zzimkkong.Constants.*; diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImplTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImplTest.java new file mode 100644 index 000000000..ee9bfbedc --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryImplTest.java @@ -0,0 +1,76 @@ +package com.woowacourse.zzimkkong.repository; + +import com.woowacourse.zzimkkong.domain.*; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDateTime; + +import static com.woowacourse.zzimkkong.Constants.*; + +public class ReservationRepositoryImplTest extends RepositoryTest { + @ParameterizedTest + @CsvSource({"true", "false"}) + @DisplayName("멤버를 이용해 오늘 이후의 예약이 존재하는지 확인할 수 있다.") + void existsReservationsByMember(boolean isReservationExists) { + // given + Member sakjung = new Member(NEW_EMAIL, PW, ORGANIZATION); + Member savedMember = members.save(sakjung); + + Map luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, savedMember); + maps.save(luther); + + Setting beSetting = Setting.builder() + .availableStartTime(BE_AVAILABLE_START_TIME) + .availableEndTime(BE_AVAILABLE_END_TIME) + .reservationTimeUnit(BE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(BE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(BE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(BE_RESERVATION_ENABLE) + .enabledDayOfWeek(BE_ENABLED_DAY_OF_WEEK) + .build(); + + Space be = Space.builder() + .name(BE_NAME) + .color(BE_COLOR) + .description(BE_DESCRIPTION) + .area(SPACE_DRAWING) + .setting(beSetting) + .map(luther) + .build(); + + spaces.save(be); + + Reservation beAmZeroOneYesterday = Reservation.builder() + .startTime(LocalDateTime.now().minusDays(1)) + .endTime(LocalDateTime.now().minusDays(1).plusHours(1)) + .description(BE_AM_TEN_ELEVEN_DESCRIPTION) + .userName(BE_AM_TEN_ELEVEN_USERNAME) + .password(BE_AM_TEN_ELEVEN_PW) + .space(be) + .build(); + + reservations.save(beAmZeroOneYesterday); + + if (isReservationExists) { + Reservation beAmZeroOne = Reservation.builder() + .startTime(BE_AM_TEN_ELEVEN_START_TIME) + .endTime(BE_AM_TEN_ELEVEN_END_TIME) + .description(BE_AM_TEN_ELEVEN_DESCRIPTION) + .userName(BE_AM_TEN_ELEVEN_USERNAME) + .password(BE_AM_TEN_ELEVEN_PW) + .space(be) + .build(); + + reservations.save(beAmZeroOne); + } + + // when + Boolean hasAnyReservations = reservations.existsReservationsByMemberFromToday(savedMember); + + // then + AssertionsForClassTypes.assertThat(hasAnyReservations).isEqualTo(isReservationExists); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java index b0b84b668..379aa2eaf 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java @@ -1,6 +1,7 @@ package com.woowacourse.zzimkkong.repository; import com.woowacourse.zzimkkong.domain.*; +import org.assertj.core.api.AssertionsForClassTypes; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,9 +24,11 @@ class ReservationRepositoryTest extends RepositoryTest { private Reservation beNextDayAmSixTwelve; private Reservation fe1ZeroOne; + private Member pobi; + @BeforeEach void setUp() { - Member pobi = new Member(EMAIL, PW, ORGANIZATION); + pobi = new Member(EMAIL, PW, ORGANIZATION); Map luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, pobi); Setting beSetting = Setting.builder() diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java index 1dc0f2152..7d0077922 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java @@ -5,9 +5,11 @@ import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; +import com.woowacourse.zzimkkong.dto.member.MemberUpdateRequest; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; +import com.woowacourse.zzimkkong.exception.member.ReservationExistsOnMemberException; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,11 +19,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; +import java.util.Optional; + import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; @@ -159,4 +162,45 @@ void saveMemberByOauthException(String oauth) { assertThatThrownBy(() -> memberService.saveMemberByOauth(oauthMemberSaveRequest)) .isInstanceOf(DuplicateEmailException.class); } + + @Test + @DisplayName("회원은 자신의 정보를 수정할 수 있다.") + void updateMember() { + // given + Member member = new Member(EMAIL, PW, ORGANIZATION); + MemberUpdateRequest memberUpdateRequest = new MemberUpdateRequest("woowabros"); + + given(members.findByEmail(any(String.class))) + .willReturn(Optional.of(member)); + + // when + memberService.updateMember(member, memberUpdateRequest); + + assertThat(members.findByEmail(EMAIL).orElseThrow().getOrganization()).isEqualTo("woowabros"); + } + + @Test + @DisplayName("회원을 삭제할 수 있다.") + void deleteMember() { + // given + Member member = new Member(1L, EMAIL, PW, ORGANIZATION); + given(reservations.existsReservationsByMemberFromToday(any(Member.class))) + .willReturn(false); + + // when, then + memberService.deleteMember(member); + } + + @Test + @DisplayName("회원이 소유한 공간에 예약이 있다면 탈퇴할 수 없다.") + void deleteMemberFailWhenAnyReservationsExists() { + // given + Member member = new Member(1L, EMAIL, PW, ORGANIZATION); + given(reservations.existsReservationsByMemberFromToday(any(Member.class))) + .willReturn(true); + + // when, then + assertThatThrownBy(() -> memberService.deleteMember(member)) + .isInstanceOf(ReservationExistsOnMemberException.class); + } } From 06581c9c5cede2a1c92512828606cfdc34cbe49e Mon Sep 17 00:00:00 2001 From: xrabcde Date: Tue, 14 Sep 2021 15:15:31 +0900 Subject: [PATCH 08/25] =?UTF-8?q?fix:=20flyway=20oauth=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=B9=BC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20(#541)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: sakjung --- backend/src/main/resources/db/migration/prod/V9__oauth.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 backend/src/main/resources/db/migration/prod/V9__oauth.sql diff --git a/backend/src/main/resources/db/migration/prod/V9__oauth.sql b/backend/src/main/resources/db/migration/prod/V9__oauth.sql new file mode 100644 index 000000000..ae2ad0cc8 --- /dev/null +++ b/backend/src/main/resources/db/migration/prod/V9__oauth.sql @@ -0,0 +1,2 @@ +alter table member add column oauth_provider varchar(10); +alter table member modify column password varchar(128); From 6b45ca4fc32ebae1558d236fd98c8b976ba9d744 Mon Sep 17 00:00:00 2001 From: Sunny K Date: Tue, 14 Sep 2021 16:25:10 +0900 Subject: [PATCH 09/25] =?UTF-8?q?refactor:=20=EA=B3=B5=EA=B0=84=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(#534)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 페이지 진입 시 map 정보 가져오기 * feat: 현재 그리기 모드 선택창 구현 * feat: 에디터 board 이동 * refactor: useBoardMove 커스텀 훅 분리 * feat: 에디터 board 확대, 축소 * refactor: Board 컴포넌트 분리 * feat: 에디터에 맵 표시 * chore: 불필요한 import 제거 * feat: 보드위에 커서위치 표시 * fix: 보드 이동이 끊기는 문제 * feat: 페이지 로딩 시 보드 중앙 배치 * refactor: 맵 요소에 일관적인 key값 적용 * refactor: MapElements -> BoardMapElement, CursorRect -> BoardCursorRect * feat: 에디터에 공간 표시 * feat: 공간선택 Select 추가 * feat: useInputs를 체크박스에 사용할 수 있도록 확장 * feat: 공간 에디터를 위한 Form 컴포넌트 구현 - SpaceFormProvider 추가 - Toggle 컴포넌트 props text -> uncheckedText * feat: 색상 선택 추가 * feat: 예약 시간 단위 선택창 추가 및 스타일 수정 * feat: 예약 최대, 최소 시간 입력창 추가 * chore: 디자인 상 잘못된 간격 수정 * feat: 예약 가능 요일 입력창 추가 * feat: Form 버튼 추가 및 불필요한 훅 제거 * chore: 잘못된 확장자 변경 * feat: Form에 입력된 공간 이름, 색상을 보드에 반영 * fix: map을 통해 렌더링한 요소에 누락된 key값 부여 * feat: 보드, 맵, 공간 정보를 이용하여 svg코드를 생성하는 util 함수 * feat: 선택한 타입의 일부 요소를 optinal로 변경할 수 있는 util type 선언 * feat: 공간 수정 * feat: 공간삭제 * feat: 사각형 공간 그리기 * feat: 공간추가 * fix: 잘못된 좌표 계산식 수정 * refactor: Editor에 useCallback, useMemo 적용 * feat: ManagerSpaceEditor 적용 * feat: 공간 형태를 그리는 단계에서 폼 요소를 화면에 띄움 * feat: PresetNameModal 컴포넌트 추가 * feat: 프리셋 조회 * feat: 프리셋 선택 * feat: 프리셋 삭제 * feat: 프리셋 추가 * fix: 삭제된 공간이 에디터에서 바로 사라지지 않는 문제 * chore: 폴더명 변경 ManagerSpaceEdit -> ManagerSpaceEditor - 페이지 컴포넌트 폴더 내 선언한 타입을 공통 에디터 타입 폴더로 이동 * refactor: 하드코딩된 문자열 enum 값으로 변경 * fix: 테스트에서 Modal 컴포넌트의 root를 찾지 못하는 문제 * chore: 불필요한 코드 제거 * chore: import 방식 변경 * chore: 사용되지 않는 상수 제거 * refactor: JSON.parse()에 try-catch 추가 * chore: 줄바꿈 추가 * chore: Modal 컴포넌트 주석추가 * refactor: 파라미터 직접 조작하는 부분 제거 * refactor: BoardMapElement의 삼항연산자 제거 * fix: generateSvg의 따옴표 수정 --- frontend/public/index.html | 1 + frontend/src/__tests__/reservation.test.tsx | 1 - frontend/src/components/Input/Input.styles.ts | 2 +- frontend/src/components/Modal/Modal.tsx | 15 +- .../src/components/Toggle/Toggle.stories.tsx | 4 +- frontend/src/components/Toggle/Toggle.tsx | 8 +- frontend/src/constants/editor.ts | 15 +- frontend/src/constants/message.ts | 3 +- frontend/src/constants/routes.tsx | 4 +- frontend/src/hooks/useInputs.ts | 12 +- .../src/pages/ManagerMain/ManagerMain.tsx | 22 +- .../ManagerMapEditor/units/MapEditor.tsx | 45 +- .../ManagerSpaceEdit.styles.ts | 381 ----- .../ManagerSpaceEdit/ManagerSpaceEdit.tsx | 1480 ----------------- .../ManagerSpaceEditor.styles.ts | 69 + .../ManagerSpaceEditor/ManagerSpaceEditor.tsx | 187 +++ frontend/src/pages/ManagerSpaceEditor/data.ts | 62 + .../hooks/useBindKeyPress.ts | 27 + .../hooks/useBoardCoordinate.ts | 42 + .../ManagerSpaceEditor/hooks/useBoardMove.ts | 59 + .../hooks/useBoardStatus.ts | 33 + .../ManagerSpaceEditor/hooks/useBoardZoom.ts | 51 + .../hooks/useDrawingRect.ts | 57 + .../hooks/useFormContext.ts | 13 + .../providers/SpaceFormProvider.tsx | 145 ++ .../ManagerSpaceEditor/units/Board.styles.ts | 29 + .../pages/ManagerSpaceEditor/units/Board.tsx | 74 + .../units/BoardCursorRect.tsx | 18 + .../units/BoardMapElement.tsx | 36 + .../units/BoardSpace.styles.ts | 24 + .../ManagerSpaceEditor/units/BoardSpace.tsx | 34 + .../ManagerSpaceEditor/units/ColorDot.ts | 27 + .../pages/ManagerSpaceEditor/units/Editor.tsx | 146 ++ .../units/EditorHeader.styles.ts | 13 + .../ManagerSpaceEditor/units/EditorHeader.tsx | 24 + .../ManagerSpaceEditor/units/Form.styles.ts | 119 ++ .../pages/ManagerSpaceEditor/units/Form.tsx | 279 ++++ .../units/FormTimeUnitSelect.styles.ts | 12 + .../units/FormTimeUnitSelect.tsx | 31 + .../units/FormWeekdaySelect.styles.ts | 32 + .../units/FormWeekdaySelect.tsx | 73 + .../ManagerSpaceEditor/units/GridPattern.tsx | 51 + .../ManagerSpaceEditor/units/Preset.styles.ts | 27 + .../pages/ManagerSpaceEditor/units/Preset.tsx | 157 ++ .../units/PresetNameModal.styles.ts | 7 + .../units/PresetNameModal.tsx | 48 + .../units/ShapeSelectToolbar.styles.ts | 33 + .../units/ShapeSelectToolbar.tsx | 37 + .../units/SpaceAddButton.tsx | 25 + .../units/SpaceSelect.styles.ts | 22 + .../ManagerSpaceEditor/units/SpaceSelect.tsx | 59 + frontend/src/types/common.ts | 9 +- frontend/src/types/editor.ts | 16 +- frontend/src/types/util.ts | 1 + frontend/src/utils/generateSvg.ts | 75 + frontend/src/utils/map.ts | 2 +- frontend/src/utils/sort.ts | 41 + 57 files changed, 2385 insertions(+), 1934 deletions(-) delete mode 100644 frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.styles.ts delete mode 100644 frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/data.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/hooks/useBindKeyPress.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/hooks/useBoardCoordinate.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/hooks/useBoardMove.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/hooks/useBoardStatus.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/hooks/useBoardZoom.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/hooks/useDrawingRect.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/hooks/useFormContext.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/Board.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/Board.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/BoardCursorRect.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/BoardMapElement.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/ColorDot.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/Editor.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/Form.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/Form.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/GridPattern.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/Preset.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/PresetNameModal.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/PresetNameModal.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/SpaceAddButton.tsx create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.styles.ts create mode 100644 frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.tsx create mode 100644 frontend/src/types/util.ts create mode 100644 frontend/src/utils/generateSvg.ts create mode 100644 frontend/src/utils/sort.ts diff --git a/frontend/public/index.html b/frontend/public/index.html index ed0059d84..1d2636a24 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -19,5 +19,6 @@
+ diff --git a/frontend/src/__tests__/reservation.test.tsx b/frontend/src/__tests__/reservation.test.tsx index e3595ad38..c48811b93 100644 --- a/frontend/src/__tests__/reservation.test.tsx +++ b/frontend/src/__tests__/reservation.test.tsx @@ -1,5 +1,4 @@ import userEvent from '@testing-library/user-event'; - import { MemoryRouter, Route, Switch } from 'react-router-dom'; import MESSAGE from 'constants/message'; import { HREF } from 'constants/path'; diff --git a/frontend/src/components/Input/Input.styles.ts b/frontend/src/components/Input/Input.styles.ts index 89878591b..8945d97c0 100644 --- a/frontend/src/components/Input/Input.styles.ts +++ b/frontend/src/components/Input/Input.styles.ts @@ -74,5 +74,5 @@ export const Message = styled.p` ${({ status = 'default' }) => statusCSS[status]}; font-size: 0.75rem; position: absolute; - margin: 0.25rem 0.75rem; + margin: 0.25rem 0.5rem; `; diff --git a/frontend/src/components/Modal/Modal.tsx b/frontend/src/components/Modal/Modal.tsx index 2cf5052ee..23f271958 100644 --- a/frontend/src/components/Modal/Modal.tsx +++ b/frontend/src/components/Modal/Modal.tsx @@ -1,4 +1,5 @@ import { MouseEventHandler, PropsWithChildren } from 'react'; +import { createPortal } from 'react-dom'; import { ReactComponent as CloseIcon } from 'assets/svg/close.svg'; import * as Styled from './Modal.styles'; @@ -9,6 +10,8 @@ export interface Props { onClose: () => void; } +let modalRoot = document.getElementById('modal'); + const Modal = ({ open, isClosableDimmer, @@ -16,13 +19,20 @@ const Modal = ({ onClose, children, }: PropsWithChildren): JSX.Element => { + if (modalRoot === null) { + // Note: 테스트(Jest)에서 modalRoot를 인식하지 못하는 문제해결 + modalRoot = document.createElement('div'); + modalRoot.setAttribute('id', 'modal'); + document.body.appendChild(modalRoot); + } + const handleMouseDownOverlay: MouseEventHandler = ({ target, currentTarget }) => { if (isClosableDimmer && target === currentTarget) { onClose(); } }; - return ( + return createPortal( {open && showCloseButton && ( @@ -32,7 +42,8 @@ const Modal = ({ )} {open && children} - + , + modalRoot ); }; diff --git a/frontend/src/components/Toggle/Toggle.stories.tsx b/frontend/src/components/Toggle/Toggle.stories.tsx index ad11c294a..7eeac31e3 100644 --- a/frontend/src/components/Toggle/Toggle.stories.tsx +++ b/frontend/src/components/Toggle/Toggle.stories.tsx @@ -23,7 +23,7 @@ export const Default = Template.bind({}); Default.args = { variant: 'default', checked: false, - text: '토글 비활성화됨', + uncheckedText: '토글 비활성화됨', checkedText: '토글 활성화됨', textPosition: 'left', }; @@ -32,7 +32,7 @@ export const Primary = Template.bind({}); Primary.args = { variant: 'primary', checked: false, - text: '토글 비활성화됨', + uncheckedText: '토글 비활성화됨', checkedText: '토글 활성화됨', textPosition: 'right', }; diff --git a/frontend/src/components/Toggle/Toggle.tsx b/frontend/src/components/Toggle/Toggle.tsx index 46b8f80ae..3651c23b8 100644 --- a/frontend/src/components/Toggle/Toggle.tsx +++ b/frontend/src/components/Toggle/Toggle.tsx @@ -3,14 +3,14 @@ import * as Styled from './Toggle.styles'; export interface Props extends InputHTMLAttributes { variant?: 'default' | 'primary'; - text: string; + uncheckedText: string; checkedText: string; textPosition?: 'left' | 'right'; } const Toggle = ({ variant = 'default', - text, + uncheckedText, checkedText, checked, textPosition = 'right', @@ -19,14 +19,14 @@ const Toggle = ({ return ( {textPosition === 'left' && ( - {checked ? checkedText : text} + {checked ? checkedText : uncheckedText} )} {textPosition === 'right' && ( - {checked ? checkedText : text} + {checked ? checkedText : uncheckedText} )} ); diff --git a/frontend/src/constants/editor.ts b/frontend/src/constants/editor.ts index 700f99d91..4207e1cd8 100644 --- a/frontend/src/constants/editor.ts +++ b/frontend/src/constants/editor.ts @@ -1,18 +1,5 @@ import PALETTE from './palette'; -export enum Mode { - SELECT = 'select', - MOVE = 'move', - LINE = 'line', - POLYLINE = 'polyline', - DECORATION = 'decoration', -} - -export enum DrawingAreaShape { - RECT = 'rect', - POLYGON = 'polygon', -} - export const MAP_COLOR_PALETTE = [ PALETTE.BLACK[400], PALETTE.GRAY[400], @@ -25,6 +12,8 @@ export const MAP_COLOR_PALETTE = [ ]; export const BOARD = { + DEFAULT_WIDTH: 800, + DEFAULT_HEIGHT: 600, MAX_WIDTH: 5000, MIN_WIDTH: 100, MAX_HEIGHT: 5000, diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index a79115ed9..3534925f3 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -55,10 +55,11 @@ const MESSAGE = { '프리셋을 삭제하는 중에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', SPACE_CREATED: '공간이 생성되었습니다.', SPACE_SETTING_UPDATED: '공간 설정이 수정되었습니다.', - SPACE_DELETED: '공간이 생성되었습니다.', + SPACE_DELETED: '공간이 삭제되었습니다.', PRESET_CREATED: '프리셋이 추가되었습니다.', PRESET_DELETED: '프리셋이 삭제되었습니다.', DELETE_PRESET_CONFIRM: '이 프리셋을 삭제하시겠어요?', + FIND_PRESET_ERROR: '프리셋을 찾을 수 없습니다.', }, MANAGER_MAP: { CREATE_SUCCESS_CONFIRM: '맵 생성 완료! 공간을 편집하러 가시겠어요?', diff --git a/frontend/src/constants/routes.tsx b/frontend/src/constants/routes.tsx index ddbc63063..e9d8e77a7 100644 --- a/frontend/src/constants/routes.tsx +++ b/frontend/src/constants/routes.tsx @@ -7,7 +7,7 @@ import ManagerLogin from 'pages/ManagerLogin/ManagerLogin'; import ManagerMain from 'pages/ManagerMain/ManagerMain'; import ManagerMapEditor from 'pages/ManagerMapEditor/ManagerMapEditor'; import ManagerReservationEdit from 'pages/ManagerReservationEdit/ManagerReservationEdit'; -import ManagerSpaceEdit from 'pages/ManagerSpaceEdit/ManagerSpaceEdit'; +import ManagerSpaceEditor from 'pages/ManagerSpaceEditor/ManagerSpaceEditor'; import PATH from './path'; interface Route { @@ -69,7 +69,7 @@ export const PRIVATE_ROUTES: PrivateRoute[] = [ }, { path: PATH.MANAGER_SPACE_EDIT, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, ]; diff --git a/frontend/src/hooks/useInputs.ts b/frontend/src/hooks/useInputs.ts index 4e49c3398..75193a694 100644 --- a/frontend/src/hooks/useInputs.ts +++ b/frontend/src/hooks/useInputs.ts @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -const useInputs = >( +const useInputs = >( initialValues: T ): [ T, @@ -9,10 +9,18 @@ const useInputs = >( ] => { const [values, setValues] = useState(initialValues); + const getValue = (event: React.ChangeEvent) => { + if (event.target.type === 'checkbox') { + return event.target.checked; + } + + return event.target.value; + }; + const handleChange = (event: React.ChangeEvent) => { setValues((prevValues) => ({ ...prevValues, - [event.target.name]: event.target.value, + [event.target.name]: getValue(event), })); }; diff --git a/frontend/src/pages/ManagerMain/ManagerMain.tsx b/frontend/src/pages/ManagerMain/ManagerMain.tsx index 4a303dfdf..0509ce71a 100644 --- a/frontend/src/pages/ManagerMain/ManagerMain.tsx +++ b/frontend/src/pages/ManagerMain/ManagerMain.tsx @@ -25,6 +25,7 @@ import useManagerReservations from 'hooks/useManagerReservations'; import { Order, Reservation } from 'types/common'; import { ErrorResponse, MapItemResponse } from 'types/response'; import { formatDate } from 'utils/datetime'; +import { sortReservations } from 'utils/sort'; import { isNullish } from 'utils/type'; import * as Styled from './ManagerMain.styles'; @@ -41,7 +42,7 @@ const ManagerMain = (): JSX.Element => { const [selectedMapId, setSelectedMapId] = useState(location.state?.mapId ?? null); const [selectedMapName, setSelectedMapName] = useState(''); - const [spacesOrder, setSpacesOrder] = useState('ascending'); + const [spacesOrder, setSpacesOrder] = useState(Order.Ascending); const onRequestError = (error: AxiosError) => { alert(error.response?.data?.message ?? MESSAGE.MANAGER_MAIN.UNEXPECTED_GET_DATA_ERROR); @@ -77,27 +78,12 @@ const ManagerMain = (): JSX.Element => { const reservations = useMemo(() => getReservations.data?.data?.data ?? [], [getReservations]); const sortedReservations = useMemo( - () => - reservations.sort((a, b) => { - if (a.spaceColor !== b.spaceColor) { - if (spacesOrder === 'ascending') return a.spaceColor < b.spaceColor ? -1 : 1; - - return a.spaceColor > b.spaceColor ? -1 : 1; - } - - const aSpaceNameWithoutWhitespace = a.spaceName.replaceAll(' ', ''); - const bSpaceNameWithoutWhitespace = b.spaceName.replaceAll(' ', ''); - - if (spacesOrder === 'ascending') - return aSpaceNameWithoutWhitespace < bSpaceNameWithoutWhitespace ? -1 : 1; - - return aSpaceNameWithoutWhitespace > bSpaceNameWithoutWhitespace ? -1 : 1; - }), + () => sortReservations(reservations, spacesOrder), [reservations, spacesOrder] ); const handleClickSpacesOrder = () => { - setSpacesOrder((prev) => (prev === 'ascending' ? 'descending' : 'ascending')); + setSpacesOrder((prev) => (prev === Order.Ascending ? Order.Descending : Order.Ascending)); }; const removeMap = useMutation(deleteMap, { diff --git a/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx index 8896ee94e..285d71385 100644 --- a/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx +++ b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx @@ -10,7 +10,7 @@ import { EDITOR, KEY } from 'constants/editor'; import PALETTE from 'constants/palette'; import useBoardCoordinate from 'pages/ManagerMapEditor/hooks/useBoardCoordinate'; import { Color, DrawingStatus, ManagerSpace, MapElement } from 'types/common'; -import { MapElementType, Mode } from 'types/editor'; +import { MapElementType, MapEditorMode } from 'types/editor'; import useBindKeyPress from '../hooks/useBindKeyPress'; import useBoardEraserTool from '../hooks/useBoardEraserTool'; import useBoardLineTool from '../hooks/useBoardLineTool'; @@ -25,27 +25,27 @@ import * as Styled from './MapEditor.styles'; const toolbarItems = [ { text: '선택', - mode: Mode.Select, + mode: MapEditorMode.Select, icon: , }, { text: '이동', - mode: Mode.Move, + mode: MapEditorMode.Move, icon: , }, { text: '선', - mode: Mode.Line, + mode: MapEditorMode.Line, icon: , }, { text: '사각형', - mode: Mode.Rect, + mode: MapEditorMode.Rect, icon: , }, { text: '지우개', - mode: Mode.Eraser, + mode: MapEditorMode.Eraser, icon: , }, ]; @@ -64,7 +64,7 @@ const MapCreateEditor = ({ mapElementsState: [mapElements, setMapElements], boardState: [{ width, height }, onChangeBoard], }: Props): JSX.Element => { - const [mode, setMode] = useState(Mode.Select); + const [mode, setMode] = useState(MapEditorMode.Select); const [color, setColor] = useState(PALETTE.BLACK[400]); const [isColorPickerOpen, setColorPickerOpen] = useState(false); @@ -73,9 +73,10 @@ const MapCreateEditor = ({ const { pressedKey } = useBindKeyPress(); const isPressSpacebar = pressedKey === KEY.SPACE; - const isBoardDraggable = isPressSpacebar || mode === Mode.Move; - const isMapElementClickable = mode === Mode.Select && !isBoardDraggable; - const isMapElementEventAvailable = [Mode.Select, Mode.Eraser].includes(mode) && !isBoardDraggable; + const isBoardDraggable = isPressSpacebar || mode === MapEditorMode.Move; + const isMapElementClickable = mode === MapEditorMode.Select && !isBoardDraggable; + const isMapElementEventAvailable = + [MapEditorMode.Select, MapEditorMode.Eraser].includes(mode) && !isBoardDraggable; const [boardStatus, setBoardStatus] = useBoardStatus({ width: Number(width), @@ -107,7 +108,7 @@ const MapCreateEditor = ({ const toggleColorPicker = () => setColorPickerOpen((prevState) => !prevState); - const selectMode = (mode: Mode) => { + const selectMode = (mode: MapEditorMode) => { setDrawingStatus({}); setMode(mode); }; @@ -115,17 +116,17 @@ const MapCreateEditor = ({ const handleMouseDown = () => { if (isBoardDraggable || isDragging) return; - if (mode === Mode.Line) drawLineStart(); - else if (mode === Mode.Rect) drawRectStart(); - else if (mode === Mode.Eraser) eraseStart(); + if (mode === MapEditorMode.Line) drawLineStart(); + else if (mode === MapEditorMode.Rect) drawRectStart(); + else if (mode === MapEditorMode.Eraser) eraseStart(); }; const handleMouseUp = () => { if (isBoardDraggable || isDragging) return; - if (mode === Mode.Line) drawLineEnd(); - else if (mode === Mode.Rect) drawRectEnd(); - else if (mode === Mode.Eraser) eraseEnd(); + if (mode === MapEditorMode.Line) drawLineEnd(); + else if (mode === MapEditorMode.Rect) drawRectEnd(); + else if (mode === MapEditorMode.Eraser) eraseEnd(); }; const deleteMapElement = useCallback(() => { @@ -139,7 +140,7 @@ const MapCreateEditor = ({ }, [deselectMapElement, selectedMapElementId, setMapElements]); useEffect(() => { - if (mode !== Mode.Select) return; + if (mode !== MapEditorMode.Select) return; const isPressedDeleteKey = pressedKey === KEY.DELETE || pressedKey === KEY.BACK_SPACE; @@ -184,7 +185,7 @@ const MapCreateEditor = ({ onDragEnd={onDragEnd} onMouseOut={onMouseOut} > - {[Mode.Line, Mode.Rect].includes(mode) && ( + {[MapEditorMode.Line, MapEditorMode.Rect].includes(mode) && ( ))} - {drawingStatus.start && mode === Mode.Line && ( + {drawingStatus.start && mode === MapEditorMode.Line && ( )} - {drawingStatus.start && mode === Mode.Rect && ( + {drawingStatus.start && mode === MapEditorMode.Rect && ( ( ))} diff --git a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.styles.ts b/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.styles.ts deleted file mode 100644 index 764210253..000000000 --- a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.styles.ts +++ /dev/null @@ -1,381 +0,0 @@ -import styled, { css } from 'styled-components'; -import Button from 'components/Button/Button'; -import IconButton from 'components/IconButton/IconButton'; -import { Z_INDEX } from 'constants/style'; -import { Color } from 'types/common'; - -interface ToolbarButtonProps { - selected?: boolean; -} - -interface ColorDotProps { - color: Color; - size: 'medium' | 'large'; -} - -interface WeekdayProps { - value?: 'Saturday' | 'Sunday'; -} - -interface BoardContainerProps { - isDraggable?: boolean; - isDragging?: boolean; -} - -interface FormContainerProps { - disabled: boolean; -} - -interface SpaceAreaRectProps { - disabled: boolean; -} - -export const Page = styled.div` - padding: 2rem 0; - display: flex; - flex-direction: column; - gap: 1rem; - height: calc(100vh - 3rem); -`; - -export const EditorHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const MapName = styled.h3` - font-size: 1.5rem; -`; - -export const ButtonContainer = styled.div``; - -export const EditorContainer = styled.div` - flex: 1; - display: flex; - justify-content: space-between; - gap: 3rem; - overflow: hidden; -`; - -export const EditorContent = styled.div` - position: relative; - display: flex; - flex: 2; - height: 100%; - border: 1px solid ${({ theme }) => theme.gray[300]}; - border-radius: 0.25rem; -`; - -export const Editor = styled.div` - flex: 1; - height: 100%; -`; - -export const FormContainer = styled.div` - position: relative; - display: flex; - flex-direction: column; - flex: 1; - height: 100%; - min-width: 20rem; - border: 1px solid ${({ theme }) => theme.gray[300]}; - border-radius: 0.25rem; - - &::before { - ${({ disabled }) => (disabled ? `content: ''` : '')}; - position: absolute; - top: 0; - left: 0; - display: block; - width: 100%; - height: 100%; - background-color: ${({ theme }) => theme.gray[200]}; - opacity: 0.3; - z-index: ${Z_INDEX.MODAL_OVERLAY}; - } -`; - -export const Form = styled.form` - padding: 2rem 1.5rem; - overflow-y: ${({ disabled }) => (disabled ? 'hidden' : 'auto')}; - flex: 1; -`; - -export const FormHeader = styled.h4` - font-size: 1.5rem; - margin-bottom: 1.625rem; -`; - -export const FormRow = styled.div` - margin: 2rem 0; - position: relative; - - &:last-of-type { - margin-bottom: 0; - } -`; - -export const FormLabel = styled.div` - font-size: 0.75rem; - color: ${({ theme }) => theme.gray[500]}; -`; - -export const FormSubmitContainer = styled.div` - display: flex; - justify-content: flex-end; -`; - -export const SpaceSettingHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.75rem; - - ${FormHeader} { - margin: 0; - } -`; - -export const SpaceSelect = styled.div` - border-bottom: 1px solid ${({ theme }) => theme.gray[300]}; - padding: 1.5rem; - position: relative; -`; - -export const SpaceSelectWrapper = styled.div` - margin-bottom: 0.5rem; -`; - -export const SpaceOption = styled.div` - display: flex; - align-items: center; - gap: 0.75rem; -`; - -export const AddButtonWrapper = styled.div` - display: inline-block; - position: absolute; - right: 1.5rem; - bottom: -1.25rem; - z-index: ${Z_INDEX.SPACE_ADD_BUTTON}; -`; - -export const ColorSelect = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin: 1.5rem 0; -`; - -export const PresetSelect = styled.div` - display: flex; - gap: 0.5rem; -`; - -export const PresetSelectWrapper = styled.div` - flex: 1; -`; - -export const PresetOption = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const PresetName = styled.span` - display: block; - flex: 1; -`; - -export const PresetNameFormControl = styled.div` - display: flex; - justify-content: flex-end; - margin-top: 1rem; -`; - -export const InputWrapper = styled.div` - display: flex; - gap: 1rem; - - label { - flex: 1; - } -`; - -export const InputMessage = styled.p` - font-size: 0.75rem; - margin: 0.25rem 0.75rem; - color: ${({ theme }) => theme.gray[500]}; -`; - -export const WeekdaySelect = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const WeekdayLabel = styled.label` - display: inline-flex; - flex-direction: column; - align-items: center; - cursor: pointer; -`; - -const weekdayColorCSS = { - default: css``, - Saturday: css` - color: ${({ theme }) => theme.blue[900]}; - `, - Sunday: css` - color: ${({ theme }) => theme.red[500]}; - `, -}; - -export const Weekday = styled.span` - ${({ value }) => weekdayColorCSS[value ?? 'default']}; -`; - -export const ColorDotButton = styled.button` - background: none; - border: none; - padding: 0; - cursor: pointer; -`; - -const colorDotSizeCSS = { - medium: css` - width: 1rem; - height: 1rem; - `, - large: css` - width: 1.5rem; - height: 1.5rem; - `, -}; - -export const ColorDot = styled.span` - display: inline-block; - background-color: ${({ color }) => color}; - border-radius: 50%; - ${({ size }) => colorDotSizeCSS[size]}; -`; - -export const ColorInputLabel = styled.label` - display: inline-block; - padding: 0.5rem; - border: 1px solid ${({ theme }) => theme.gray[500]}; - border-radius: 0.125rem; - cursor: pointer; -`; - -export const ColorInput = styled.input` - width: 0; - height: 0; - opacity: 0; - padding: 0; -`; - -export const RadioLabel = styled.label``; - -export const RadioInput = styled.input``; - -export const RadioLabelText = styled.span``; - -export const Fieldset = styled.div` - border: 1px solid ${({ theme }) => theme.gray[500]}; - border-radius: 0.125rem; - padding: 1rem 0.75rem; - - ${FormLabel} { - position: absolute; - top: -0.375rem; - left: 0.75rem; - padding: 0 0.25rem; - background-color: ${({ theme }) => theme.white}; - } -`; - -export const DeleteButton = styled(Button)` - color: ${({ theme }) => theme.red[900]}; - display: inline-flex; - align-items: center; - gap: 0.25rem; - - svg { - width: 1rem; - height: 1rem; - vertical-align: middle; - fill: ${({ theme }) => theme.red[900]}; - } -`; - -export const BoardContainer = styled.svg` - cursor: ${({ isDraggable, isDragging }) => { - if (isDraggable) { - if (isDragging) return 'grabbing'; - else return 'grab'; - } - return 'default'; - }}; -`; - -export const Toolbar = styled.div` - padding: 1rem 0.5rem; - background-color: ${({ theme }) => theme.gray[100]}; - border-right: 1px solid ${({ theme }) => theme.gray[300]}; - display: flex; - flex-direction: column; - gap: 1rem; -`; - -const primaryIconCSS = css` - svg { - fill: ${({ theme }) => theme.primary[400]}; - } -`; - -export const ToolbarButton = styled(IconButton)` - background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; - border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; - border-radius: 0; - box-sizing: content-box; - ${({ selected }) => selected && primaryIconCSS} -`; - -export const SpaceShapeSelect = styled.div` - position: absolute; - top: 0.5rem; - left: 50%; - transform: translateX(-50%); - margin: 0 auto; - padding: 0.5rem 1rem; - background-color: ${({ theme }) => theme.gray[100]}; - border-right: 1px solid ${({ theme }) => theme.gray[300]}; - display: flex; - gap: 1rem; -`; - -export const NoSpaceMessage = styled.p` - text-align: center; - font-size: 1.125rem; - margin: 3rem auto; -`; - -export const SpaceAreaRect = styled.rect` - opacity: 0.3; - cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; - - &:hover { - opacity: ${({ disabled }) => (disabled ? '0.3' : '0.2')}; - } -`; - -export const SpaceAreaText = styled.text` - dominant-baseline: middle; - text-anchor: middle; - fill: ${({ theme }) => theme.black[700]}; - font-size: 1rem; - pointer-events: none; - user-select: none; -`; diff --git a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.tsx b/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.tsx deleted file mode 100644 index 585e4bf07..000000000 --- a/frontend/src/pages/ManagerSpaceEdit/ManagerSpaceEdit.tsx +++ /dev/null @@ -1,1480 +0,0 @@ -import { AxiosError } from 'axios'; -import { - ChangeEventHandler, - FormEventHandler, - MouseEventHandler, - useCallback, - useEffect, - useMemo, - useRef, - useState, - WheelEventHandler, -} from 'react'; -import { useMutation } from 'react-query'; -import { Link, Redirect, useParams } from 'react-router-dom'; -import { deleteManagerSpace, postManagerSpace, putManagerSpace } from 'api/managerSpace'; -import { deletePreset, postPreset } from 'api/presets'; -import { ReactComponent as CloseIcon } from 'assets/svg/close.svg'; -import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; -import { ReactComponent as PaletteIcon } from 'assets/svg/palette.svg'; -import { ReactComponent as PlusSmallIcon } from 'assets/svg/plus-small.svg'; -import { ReactComponent as PolygonIcon } from 'assets/svg/polygon.svg'; -import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; -import Button from 'components/Button/Button'; -import Header from 'components/Header/Header'; -import IconButton from 'components/IconButton/IconButton'; -import Input from 'components/Input/Input'; -import Layout from 'components/Layout/Layout'; -import Modal from 'components/Modal/Modal'; -import Select from 'components/Select/Select'; -import Toggle from 'components/Toggle/Toggle'; -import { DrawingAreaShape, EDITOR, KEY } from 'constants/editor'; -import MESSAGE from 'constants/message'; -import PALETTE from 'constants/palette'; -import PATH from 'constants/path'; -import SPACE from 'constants/space'; -import useInput from 'hooks/useInput'; -import useListenManagerMainState from 'hooks/useListenManagerMainState'; -import useManagerMap from 'hooks/useManagerMap'; -import useManagerSpaces from 'hooks/useManagerSpaces'; -import usePresets from 'hooks/usePreset'; -import useToggle from 'hooks/useToggle'; -import { - Coordinate, - DrawingStatus, - ManagerSpace, - MapDrawing, - Preset, - SpaceArea, -} from 'types/common'; -import { ErrorResponse } from 'types/response'; -import { formatDate, formatTimeWithSecond } from 'utils/datetime'; -import * as Styled from './ManagerSpaceEdit.styles'; - -const colorSelectOptions = [ - PALETTE.RED[500], - PALETTE.ORANGE[500], - PALETTE.YELLOW[500], - PALETTE.GREEN[500], - PALETTE.BLUE[300], - PALETTE.BLUE[900], - PALETTE.PURPLE[500], -]; - -interface Params { - mapId: string; -} - -interface CreateResponseHeaders { - location: string; -} - -const ManagerSpaceEdit = (): JSX.Element => { - const editorRef = useRef(null); - const spaceNameRef = useRef(null); - const { mapId } = useParams(); - - useListenManagerMainState({ mapId: Number(mapId) }); - - const todayDate = formatDate(new Date()); - const initialStartTime = formatTimeWithSecond(new Date(`${todayDate}T07:00:00`)); - const initialEndTime = formatTimeWithSecond(new Date(`${todayDate}T23:00:00`)); - - const map = useManagerMap({ mapId: Number(mapId) }); - const mapName = map.data?.data.mapName ?? ''; - const { width, height, mapElements } = useMemo(() => { - try { - return JSON.parse(map.data?.data.mapDrawing ?? '{}') as MapDrawing; - } catch (error) { - alert(MESSAGE.MANAGER_SPACE.GET_UNEXPECTED_ERROR); - - return { width: 800, height: 600, mapElements: [] }; - } - }, [map.data?.data.mapDrawing]); - - const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }); - const spaces: ManagerSpace[] = useMemo( - () => - managerSpaces.data?.data.spaces.map((space) => ({ - ...space, - area: JSON.parse(space.area) as SpaceArea, - })) ?? [], - [managerSpaces.data?.data.spaces] - ); - const spaceOptions = spaces.map(({ id, name, color }) => ({ - value: `${id}`, - children: ( - - - {name} - - ), - })); - - const createSpace = useMutation(postManagerSpace, { - onSuccess: (response) => { - const { location } = response.headers as CreateResponseHeaders; - const newSpaceId = Number(location.split('/').pop() ?? ''); - - initializeDrawingStatus(); - setAddingSpace(false); - setSelectedSpaceId(newSpaceId); - - managerSpaces.refetch(); - alert(MESSAGE.MANAGER_SPACE.SPACE_CREATED); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_UNEXPECTED_ERROR); - }, - }); - - const updateSpace = useMutation(putManagerSpace, { - onSuccess: () => { - managerSpaces.refetch(); - alert(MESSAGE.MANAGER_SPACE.SPACE_SETTING_UPDATED); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.EDIT_UNEXPECTED_ERROR); - }, - }); - - const deleteSpace = useMutation(deleteManagerSpace, { - onSuccess: () => { - setSelectedSpaceId(null); - setArea(null); - - managerSpaces.refetch(); - alert(MESSAGE.MANAGER_SPACE.SPACE_DELETED); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_UNEXPECTED_ERROR); - }, - }); - - const getPresets = usePresets(); - const presets = useMemo( - () => getPresets.data?.data?.presets ?? [], - [getPresets.data?.data?.presets] - ); - - const createPreset = useMutation(postPreset, { - onSuccess: (response) => { - const { location } = response.headers as CreateResponseHeaders; - const newPresetId = Number(location.split('/').pop() ?? ''); - - setSelectedPresetId(newPresetId); - setPresetFormOpen(false); - setPresetName(''); - - getPresets.refetch(); - }, - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_PRESET_UNEXPECTED_ERROR); - }, - }); - - const removePreset = useMutation(deletePreset, { - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_PRESET_UNEXPECTED_ERROR); - }, - }); - - const [isPresetFormOpen, setPresetFormOpen] = useState(false); - const [presetName, onChangePresetName, setPresetName] = useInput(''); - - const [isDragging, setDragging] = useState(false); - const [isDraggable, setDraggable] = useState(false); - const [dragOffsetX, setDragOffsetX] = useState(0); - const [dragOffsetY, setDragOffsetY] = useState(0); - - const [selectedSpaceId, setSelectedSpaceId] = useState(null); - const [selectedPresetId, setSelectedPresetId] = useState(null); - - const [isDrawingArea, setDrawingArea] = useState(false); - const [isAddingSpace, setAddingSpace] = useState(false); - const [drawingAreaShape, setDrawingAreaShape] = useState(DrawingAreaShape.RECT); - const [area, setArea] = useState(null); - - const [spaceName, onChangeSpaceName, setSpaceName] = useInput(''); - const [reservationEnable, onChangeReservationEnable, setReservationEnable] = useToggle(true); - const [spaceColor, onChangeSpaceColor, setSpaceColor] = useInput(PALETTE.RED[500]); - const [availableStartTime, onChangeAvailableStartTime, setAvailableStartTime] = - useInput(initialStartTime); - const [availableEndTime, onChangeAvailableEndTime, setAvailableEndTime] = - useInput(initialEndTime); - const [reservationTimeUnit, onChangeReservationTimeUnit, setReservationTimeUnit] = useInput('10'); - const [ - reservationMinimumTimeUnit, - onChangeReservationMinimumTimeUnit, - setReservationMinimumTimeUnit, - ] = useInput('10'); - const [ - reservationMaximumTimeUnit, - onChangeReservationMaximumTimeUnit, - setReservationMaximumTimeUnit, - ] = useInput('1440'); - const [enabledWeekdays, setEnabledWeekdays] = useState({ - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: true, - sunday: true, - }); - const { monday, tuesday, wednesday, thursday, friday, saturday, sunday } = enabledWeekdays; - - const enabledDayOfWeek = Object.entries(enabledWeekdays) - .filter(([, checked]) => checked) - .map(([weekday]) => weekday) - .join(','); - - const [board, setBoard] = useState({ - width: 0, - height: 0, - x: 0, - y: 0, - scale: 1, - }); - - const [coordinate, setCoordinate] = useState({ - x: -EDITOR.GRID_SIZE, - y: -EDITOR.GRID_SIZE, - }); - const stickyCoordinate: Coordinate = useMemo( - () => ({ - x: Math.floor(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - y: Math.floor(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, - }), - [coordinate] - ); - - const [drawingStatus, setDrawingStatus] = useState({}); - const [guideArea, setGuideArea] = useState({ - shape: DrawingAreaShape.RECT, - x: 0, - y: 0, - width: 0, - height: 0, - }); - - const handleWheel: WheelEventHandler = useCallback((event) => { - const { offsetX, offsetY, deltaY } = event.nativeEvent; - - setBoard((prevState) => { - const { scale, x, y, width, height } = prevState; - - const nextScale = scale - deltaY * EDITOR.SCALE_DELTA; - - if (nextScale <= EDITOR.MIN_SCALE || nextScale >= EDITOR.MAX_SCALE) { - return { - ...prevState, - scale: prevState.scale, - }; - } - - const cursorX = (offsetX - x) / (width * scale); - const cursorY = (offsetY - y) / (height * scale); - - const widthDiff = Math.abs(width * nextScale - width * scale) * cursorX; - const heightDiff = Math.abs(height * nextScale - height * scale) * cursorY; - - const nextX = nextScale > scale ? x - widthDiff : x + widthDiff; - const nextY = nextScale > scale ? y - heightDiff : y + heightDiff; - - return { - ...prevState, - x: nextX, - y: nextY, - scale: nextScale, - }; - }); - }, []); - - const handleDragStart: MouseEventHandler = useCallback( - (event) => { - if (isDrawingArea || !isDraggable) return; - - setDragOffsetX(event.nativeEvent.offsetX - board.x); - setDragOffsetY(event.nativeEvent.offsetY - board.y); - - setDragging(true); - }, - [board.x, board.y, isDraggable, isDrawingArea] - ); - - const handleDragEnd = useCallback(() => { - setDragOffsetX(0); - setDragOffsetY(0); - - setDragging(false); - }, []); - - const handleDrag: MouseEventHandler = useCallback( - (event) => { - if (isDrawingArea || !isDragging || !isDraggable) return; - - const { offsetX, offsetY } = event.nativeEvent; - - setBoard((prevState) => ({ - ...prevState, - x: offsetX - dragOffsetX, - y: offsetY - dragOffsetY, - })); - }, - [dragOffsetX, dragOffsetY, isDraggable, isDragging, isDrawingArea] - ); - - const handleMouseOut = useCallback(() => { - setDraggable(false); - setDragging(false); - }, []); - - const getSVGCoordinate = useCallback( - (event: React.MouseEvent) => { - const svg = (event.nativeEvent.target as SVGElement)?.ownerSVGElement; - if (!svg) return { svg: null, x: -1, y: -1 }; - - let point = svg.createSVGPoint(); - - point.x = event.nativeEvent.clientX; - point.y = event.nativeEvent.clientY; - point = point.matrixTransform(svg.getScreenCTM()?.inverse()); - - const x = (point.x - board.x) * (1 / board.scale); - const y = (point.y - board.y) * (1 / board.scale); - - return { svg, x, y }; - }, - [board.scale, board.x, board.y] - ); - - const initializeDrawingStatus = useCallback(() => { - setDrawingStatus({}); - setGuideArea({ shape: DrawingAreaShape.RECT, x: 0, y: 0, width: 0, height: 0 }); - }, []); - - const initializeSpaceAddForm = useCallback(() => { - setSpaceName(''); - setSpaceColor(PALETTE.RED[500]); - setAvailableStartTime(initialStartTime); - setAvailableEndTime(initialEndTime); - setReservationTimeUnit('10'); - setReservationMinimumTimeUnit('10'); - setReservationMaximumTimeUnit('1440'); - setReservationEnable(true); - setEnabledWeekdays({ - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: true, - sunday: true, - }); - }, [ - initialEndTime, - initialStartTime, - setAvailableEndTime, - setAvailableStartTime, - setReservationEnable, - setReservationMaximumTimeUnit, - setReservationMinimumTimeUnit, - setReservationTimeUnit, - setSpaceName, - setSpaceColor, - ]); - - const handleChangeReservationForm = ( - event: React.ChangeEvent, - callback: ChangeEventHandler - ) => { - if (selectedPresetId) selectPreset(null); - - callback(event); - }; - - const handleSubmitPreset: FormEventHandler = (event) => { - event.preventDefault(); - - const availableTime = { - start: formatTimeWithSecond(new Date(`${todayDate}T${availableStartTime}`)), - end: formatTimeWithSecond(new Date(`${todayDate}T${availableEndTime}`)), - }; - - const settingsRequest = { - availableStartTime: availableTime.start, - availableEndTime: availableTime.end, - reservationTimeUnit: Number(reservationTimeUnit), - reservationMinimumTimeUnit: Number(reservationMinimumTimeUnit), - reservationMaximumTimeUnit: Number(reservationMaximumTimeUnit), - reservationEnable, - enabledDayOfWeek, - }; - - createPreset.mutate({ name: presetName, settingsRequest }); - }; - - const handleAddPreset = () => { - setPresetFormOpen(true); - }; - - const handleDeletePreset = (event: React.MouseEvent, id: Preset['id']) => { - event.stopPropagation(); - if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_PRESET_CONFIRM)) return; - - removePreset.mutate({ id }); - }; - - const selectPreset = useCallback( - (id: number | null) => { - setSelectedPresetId(id); - - if (id === null) return; - - const { - availableStartTime, - availableEndTime, - reservationTimeUnit, - reservationMinimumTimeUnit, - reservationMaximumTimeUnit, - enabledDayOfWeek, - } = presets.find((preset) => preset.id === id) ?? presets[0]; - - const enableWeekdays = enabledDayOfWeek?.toLowerCase()?.split(',') ?? []; - - setAvailableStartTime(availableStartTime); - setAvailableEndTime(availableEndTime); - setReservationTimeUnit(`${reservationTimeUnit}`); - setReservationMinimumTimeUnit(`${reservationMinimumTimeUnit}`); - setReservationMaximumTimeUnit(`${reservationMaximumTimeUnit}`); - setEnabledWeekdays({ - monday: enableWeekdays.includes('monday'), - tuesday: enableWeekdays.includes('tuesday'), - wednesday: enableWeekdays.includes('wednesday'), - thursday: enableWeekdays.includes('thursday'), - friday: enableWeekdays.includes('friday'), - saturday: enableWeekdays.includes('saturday'), - sunday: enableWeekdays.includes('sunday'), - }); - }, - [ - presets, - setAvailableEndTime, - setAvailableStartTime, - setReservationMaximumTimeUnit, - setReservationMinimumTimeUnit, - setReservationTimeUnit, - ] - ); - - const selectSpace = useCallback( - (id: number | null) => { - selectPreset(null); - setSelectedSpaceId(id); - - if (id === null) return; - - const selectedSpace = spaces.find((space) => space.id === id); - if (!selectedSpace?.id) return; - - const { name, color, area, settings } = selectedSpace; - const { - availableStartTime, - availableEndTime, - reservationTimeUnit, - reservationMinimumTimeUnit, - reservationMaximumTimeUnit, - reservationEnable, - enabledDayOfWeek, - } = settings; - - const enableWeekdays = enabledDayOfWeek?.toLowerCase()?.split(',') ?? []; - - setArea(area); - setSpaceName(name); - setSpaceColor(color ?? PALETTE.RED[500]); - setAvailableStartTime(availableStartTime); - setAvailableEndTime(availableEndTime); - setReservationTimeUnit(`${reservationTimeUnit}`); - setReservationMinimumTimeUnit(`${reservationMinimumTimeUnit}`); - setReservationMaximumTimeUnit(`${reservationMaximumTimeUnit}`); - setReservationEnable(reservationEnable); - setEnabledWeekdays({ - monday: enableWeekdays.includes('monday'), - tuesday: enableWeekdays.includes('tuesday'), - wednesday: enableWeekdays.includes('wednesday'), - thursday: enableWeekdays.includes('thursday'), - friday: enableWeekdays.includes('friday'), - saturday: enableWeekdays.includes('saturday'), - sunday: enableWeekdays.includes('sunday'), - }); - }, - [ - selectPreset, - setAvailableEndTime, - setAvailableStartTime, - setReservationEnable, - setReservationMaximumTimeUnit, - setReservationMinimumTimeUnit, - setReservationTimeUnit, - setSpaceColor, - setSpaceName, - spaces, - ] - ); - - const handleMouseMove: MouseEventHandler = useCallback( - (event) => { - const { x, y } = getSVGCoordinate(event); - setCoordinate({ x, y }); - - if (!drawingStatus.start) return; - - const [startX, endX] = - drawingStatus.start.x > stickyCoordinate.x - ? [stickyCoordinate.x, drawingStatus.start.x] - : [drawingStatus.start.x, stickyCoordinate.x]; - const [startY, endY] = - drawingStatus.start.y > stickyCoordinate.y - ? [stickyCoordinate.y, drawingStatus.start.y] - : [drawingStatus.start.y, stickyCoordinate.y]; - - const width = Math.abs(startX - endX) + EDITOR.GRID_SIZE; - const height = Math.abs(startY - endY) + EDITOR.GRID_SIZE; - - setGuideArea({ - shape: drawingAreaShape, - x: startX, - y: startY, - width, - height, - }); - }, - [ - drawingAreaShape, - drawingStatus.start, - getSVGCoordinate, - stickyCoordinate.x, - stickyCoordinate.y, - ] - ); - - const handleDrawStart: MouseEventHandler = useCallback(() => { - if (!isDrawingArea || !drawingAreaShape) return; - - setDrawingStatus((prevDrawingStatus) => ({ - ...prevDrawingStatus, - start: stickyCoordinate, - })); - }, [drawingAreaShape, isDrawingArea, stickyCoordinate]); - - const handleDrawEnd: MouseEventHandler = useCallback(() => { - if (!isDrawingArea || !drawingAreaShape) return; - if (!drawingStatus || !drawingStatus.start) return; - - const [startX, endX] = - drawingStatus.start.x > stickyCoordinate.x - ? [stickyCoordinate.x, drawingStatus.start.x] - : [drawingStatus.start.x, stickyCoordinate.x]; - const [startY, endY] = - drawingStatus.start.y > stickyCoordinate.y - ? [stickyCoordinate.y, drawingStatus.start.y] - : [drawingStatus.start.y, stickyCoordinate.y]; - - const width = Math.abs(startX - endX) + EDITOR.GRID_SIZE; - const height = Math.abs(startY - endY) + EDITOR.GRID_SIZE; - - setArea({ - shape: DrawingAreaShape.RECT, - x: startX, - y: startY, - width, - height, - }); - - initializeSpaceAddForm(); - initializeDrawingStatus(); - setDrawingArea(false); - - spaceNameRef.current?.focus(); - }, [ - drawingAreaShape, - drawingStatus, - initializeDrawingStatus, - initializeSpaceAddForm, - isDrawingArea, - stickyCoordinate.x, - stickyCoordinate.y, - ]); - - const handleChangeEnabledDayOfWeek: ChangeEventHandler = useCallback( - (event) => { - const { name, checked } = event.target; - - setEnabledWeekdays((prevDayOfWeek) => ({ - ...prevDayOfWeek, - [name]: checked, - })); - }, - [] - ); - - const handleCancelForm = useCallback(() => { - if (!window.confirm(MESSAGE.MANAGER_SPACE.CANCEL_ADD_SPACE_CONFIRM)) return; - if (!selectedSpaceId) setArea(null); - - initializeDrawingStatus(); - setAddingSpace(false); - selectSpace(selectedSpaceId); - }, [initializeDrawingStatus, selectSpace, selectedSpaceId]); - - const handleCancelAddingSpace = useCallback(() => { - if (selectedSpaceId) selectSpace(selectedSpaceId); - - initializeDrawingStatus(); - setAddingSpace(false); - setDrawingArea(false); - }, [initializeDrawingStatus, selectSpace, selectedSpaceId]); - - const handleDeleteSpace = useCallback(() => { - if (!selectedSpaceId) return; - if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_SPACE_CONFIRM)) return; - - const mapImageSvg = ` - - ${spaces - .filter(({ id }) => id !== selectedSpaceId) - .map( - ({ color, area }) => ` - - - - ` - ) - .join('')} - ${mapElements - ?.map( - ({ points, stroke }) => ` - - ` - ) - .join('')} - -` - .replace(/(\r\n\t|\n|\r\t|\s{1,})/gm, ' ') - .replace(/\s{2,}/g, ' '); - - deleteSpace.mutate({ - mapId: Number(mapId), - spaceId: selectedSpaceId, - mapImageSvg, - }); - }, [deleteSpace, height, mapElements, mapId, selectedSpaceId, spaces, width]); - - const handleAddSpace = useCallback(() => { - setArea(null); - initializeDrawingStatus(); - setAddingSpace(true); - setDrawingArea(true); - }, [initializeDrawingStatus]); - - const handleClickSpaceArea = useCallback( - (id) => { - if (isAddingSpace) return; - - selectSpace(id); - }, - [selectSpace, isAddingSpace] - ); - - const handleSubmitSpace: FormEventHandler = useCallback( - (event) => { - event.preventDefault(); - - const availableTime = { - start: formatTimeWithSecond(new Date(`${todayDate}T${availableStartTime}`)), - end: formatTimeWithSecond(new Date(`${todayDate}T${availableEndTime}`)), - }; - - const targetSpaces = - selectedSpaceId && !isAddingSpace - ? spaces.filter(({ id }) => selectedSpaceId !== id) - : spaces; - - const mapImageSvg = ` - - ${ - area - ? ` - - - - ` - : '' - } - - ${targetSpaces - .map( - ({ color, area }) => ` - - - - ` - ) - .join('')} - - ${mapElements - .map((element) => - element.type === 'polyline' - ? ` - - ` - : ` - - ` - ) - .join('')} - - ` - .replace(/(\r\n\t|\n|\r\t|\s{1,})/gm, ' ') - .replace(/\s{2,}/g, ' '); - - if (isAddingSpace) { - createSpace.mutate({ - mapId: Number(mapId), - space: { - name: spaceName, - color: spaceColor, - description: spaceName, - area: JSON.stringify(area), - settingsRequest: { - availableStartTime: availableTime.start, - availableEndTime: availableTime.end, - reservationTimeUnit: Number(reservationTimeUnit), - reservationMinimumTimeUnit: Number(reservationMinimumTimeUnit), - reservationMaximumTimeUnit: Number(reservationMaximumTimeUnit), - reservationEnable, - enabledDayOfWeek, - }, - mapImageSvg, - }, - }); - - return; - } - - if (selectedSpaceId && !isAddingSpace) { - updateSpace.mutate({ - mapId: Number(mapId), - spaceId: selectedSpaceId, - space: { - name: spaceName, - color: spaceColor, - description: spaceName, - area: JSON.stringify(area), - settingsRequest: { - availableStartTime: availableTime.start, - availableEndTime: availableTime.end, - reservationTimeUnit: Number(reservationTimeUnit), - reservationMinimumTimeUnit: Number(reservationMinimumTimeUnit), - reservationMaximumTimeUnit: Number(reservationMaximumTimeUnit), - reservationEnable, - enabledDayOfWeek, - }, - mapImageSvg, - }, - }); - } - }, - [ - area, - availableEndTime, - availableStartTime, - createSpace, - enabledDayOfWeek, - height, - isAddingSpace, - mapElements, - mapId, - reservationEnable, - reservationMaximumTimeUnit, - reservationMinimumTimeUnit, - reservationTimeUnit, - selectedSpaceId, - spaceColor, - spaceName, - spaces, - todayDate, - updateSpace, - width, - ] - ); - - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if ((event.target as HTMLElement).tagName === 'INPUT') return; - - if (event.key === KEY.SPACE) setDraggable(true); - }, []); - - const handleKeyUp = useCallback((event: KeyboardEvent) => { - if (event.key === KEY.SPACE) setDraggable(false); - }, []); - - const presetOptions = presets.map(({ id, name }) => ({ - value: `${id}`, - title: name, - children: ( - - {name} - handleDeletePreset(event, id)}> - - - - ), - })); - - useEffect(() => { - const editorWidth = editorRef.current ? editorRef.current.offsetWidth : 0; - const editorHeight = editorRef.current ? editorRef.current.offsetHeight : 0; - - setBoard((prevState) => ({ - ...prevState, - x: (editorWidth - board.width) / 2, - y: (editorHeight - board.height) / 2, - })); - }, [board.width, board.height]); - - useEffect(() => { - if (width && height) { - setBoard((prevBoard) => ({ - ...prevBoard, - width, - height, - })); - } - }, [width, height]); - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('keyup', handleKeyUp); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('keyup', handleKeyUp); - }; - }, [handleKeyDown, handleKeyUp]); - - if ( - (map.isError && map.error?.response?.status === 401) || - (managerSpaces.isError && managerSpaces.error?.response?.status === 401) - ) { - return ; - } - - if (map.isError || managerSpaces.isError) { - return ; - } - - return ( - <> -
- - - - {mapName} - - - - - - - - - {isDrawingArea && ( - - - - - setDrawingAreaShape(DrawingAreaShape.RECT)} - > - - - - {/* NOTE 추후 다각형 기능 구현 시, 이 부분의 주석을 해제하고 작성하면 됩니다. */} - {/* setDrawingAreaShape(DrawingAreaShape.POLYGON)} - > - - */} - - )} - - - - - - - - - - - - - - - - - {/* Note: 새로 그려지는 중인 공간의 영역 */} - {guideArea && ( - - )} - - {/* Note: 커서 위치 표시 */} - {isDrawingArea && ( - - )} - - {/* Note: 모눈 표시 */} - - - {/* Note: 현재 추가 혹은 삭제 중인 공간의 영역 */} - {area && ( - - {area.shape === DrawingAreaShape.RECT && ( - - )} - - - {spaceName} - - - )} - - {/* Note: 공간 영역 */} - {spaces.map(({ id, color, area, name }) => ( - - handleClickSpaceArea(id)} - disabled={isAddingSpace || id === selectedSpaceId} - /> - {(isAddingSpace || id !== selectedSpaceId) && ( - - {name} - - )} - - ))} - - {/* Note: 맵 요소 */} - {mapElements?.map((element) => - element.type === 'polyline' ? ( - - ) : ( - - ) - )} - - - - - - - - 공간 선택 - - } - label="공간 이름" - value={spaceName} - onChange={onChangeSpaceName} - ref={spaceNameRef} - required - /> - - - - - - - - {colorSelectOptions.map((color) => ( - setSpaceColor(color)} - > - - - ))} - - - 예약 조건 - {/* Note: 프리셋 기능을 구현할 때 이 UI를 활성화하면 됩니다 */} - - - - - handleChangeReservationForm(event, onChangeAvailableStartTime) - } - required - /> - - handleChangeReservationForm(event, onChangeAvailableEndTime) - } - required - /> - - - 예약이 열리는 시간과 닫히는 시간을 설정할 수 있습니다. - - - - - - 예약 시간 단위 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - required - /> - 5분 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - /> - 10분 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - /> - 30분 - - - - handleChangeReservationForm(event, onChangeReservationTimeUnit) - } - name="time-unit" - /> - 1시간 - - - - - 예약할 때의 시간 단위를 설정할 수 있습니다. - - - - - - - handleChangeReservationForm(event, onChangeReservationMinimumTimeUnit) - } - required - /> - - handleChangeReservationForm(event, onChangeReservationMaximumTimeUnit) - } - required - /> - - - 예약 가능한 최소 시간과 최대 시간을 설정할 수 있습니다. - - - - - - 예약 가능한 요일 - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - handleChangeReservationForm(event, handleChangeEnabledDayOfWeek) - } - /> - - - - - - {isAddingSpace ? ( - - - - - ) : ( - - - - 공간 삭제 - - - - )} - - - ) : ( - 공간을 선택해주세요 - )} - - - - - setPresetFormOpen(false)} - > - 추가할 프리셋의 이름을 입력해주세요 - - - - - - - - - - - ); -}; - -export default ManagerSpaceEdit; diff --git a/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.styles.ts b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.styles.ts new file mode 100644 index 000000000..524dfcbc8 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.styles.ts @@ -0,0 +1,69 @@ +import styled from 'styled-components'; +import { Z_INDEX } from 'constants/style'; + +interface FormContainerProps { + disabled: boolean; +} + +export const Page = styled.div` + padding: 2rem 0; + display: flex; + flex-direction: column; + gap: 1rem; + height: calc(100vh - 3rem); +`; + +export const EditorMain = styled.div` + flex: 1; + display: flex; + justify-content: space-between; + gap: 1.5rem; + overflow: hidden; +`; + +export const EditorContainer = styled.div` + position: relative; + display: flex; + flex: 2; + height: 100%; + border: 1px solid ${({ theme }) => theme.gray[300]}; + border-radius: 0.25rem; +`; + +export const FormContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-width: 22rem; + border: 1px solid ${({ theme }) => theme.gray[300]}; + border-radius: 0.25rem; + + &::before { + ${({ disabled }) => (disabled ? `content: ''` : '')}; + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; + background-color: ${({ theme }) => theme.gray[200]}; + opacity: 0.3; + z-index: ${Z_INDEX.MODAL_OVERLAY}; + } +`; + +export const AddButtonWrapper = styled.div` + display: inline-block; + position: absolute; + right: 1.5rem; + bottom: -1.25rem; + z-index: ${Z_INDEX.SPACE_ADD_BUTTON}; +`; + +export const NoSpaceMessage = styled.p` + text-align: center; + font-size: 1.125rem; + margin: 3rem auto; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.tsx b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.tsx new file mode 100644 index 000000000..0c544cc83 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/ManagerSpaceEditor.tsx @@ -0,0 +1,187 @@ +import { AxiosError } from 'axios'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useParams } from 'react-router-dom'; +import { + deleteManagerSpace, + DeleteManagerSpaceParams, + postManagerSpace, + PostManagerSpaceParams, + putManagerSpace, + PutManagerSpaceParams, +} from 'api/managerSpace'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import { BOARD } from 'constants/editor'; +import MESSAGE from 'constants/message'; +import useListenManagerMainState from 'hooks/useListenManagerMainState'; +import useManagerMap from 'hooks/useManagerMap'; +import useManagerSpaces from 'hooks/useManagerSpaces'; +import { ManagerSpace, MapDrawing, SpaceArea } from 'types/common'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import { ErrorResponse } from 'types/response'; +import * as Styled from './ManagerSpaceEditor.styles'; +import { drawingModes } from './data'; +import useBoardStatus from './hooks/useBoardStatus'; +import SpaceFormProvider from './providers/SpaceFormProvider'; +import Editor from './units/Editor'; +import EditorHeader from './units/EditorHeader'; +import Form from './units/Form'; +import ShapeSelectToolbar from './units/ShapeSelectToolbar'; +import SpaceAddButton from './units/SpaceAddButton'; +import SpaceSelect from './units/SpaceSelect'; + +interface CreateResponseHeaders { + location: string; +} + +const ManagerSpaceEditor = (): JSX.Element => { + const { mapId } = useParams<{ mapId: string }>(); + useListenManagerMainState({ mapId: Number(mapId) }); + const map = useManagerMap({ mapId: Number(mapId) }); + const mapName = map.data?.data.mapName ?? ''; + const { width, height, mapElements } = useMemo(() => { + try { + return JSON.parse(map.data?.data.mapDrawing ?? '{}') as MapDrawing; + } catch (error) { + alert(MESSAGE.MANAGER_SPACE.GET_UNEXPECTED_ERROR); + + return { width: BOARD.DEFAULT_WIDTH, height: BOARD.DEFAULT_HEIGHT, mapElements: [] }; + } + }, [map.data?.data.mapDrawing]); + + const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }); + const spaces: ManagerSpace[] = useMemo(() => { + try { + return ( + managerSpaces.data?.data.spaces.map((space) => ({ + ...space, + area: JSON.parse(space.area) as SpaceArea, + })) ?? [] + ); + } catch (error) { + alert(MESSAGE.MANAGER_SPACE.GET_UNEXPECTED_ERROR); + + return []; + } + }, [managerSpaces.data?.data.spaces]); + + const [mode, setMode] = useState(Mode.Default); + const [board, setBoard] = useBoardStatus({ width, height }); + const [selectedSpaceId, setSelectedSpaceId] = useState(null); + + const isDrawingMode = drawingModes.includes(mode); + + const createSpace = useMutation(postManagerSpace, { + onSuccess: async (response) => { + const { location } = response.headers as CreateResponseHeaders; + const newSpaceId = Number(location.split('/').pop() ?? ''); + + await managerSpaces.refetch(); + setSelectedSpaceId(newSpaceId); + alert(MESSAGE.MANAGER_SPACE.SPACE_CREATED); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_UNEXPECTED_ERROR); + }, + }); + + const updateSpace = useMutation(putManagerSpace, { + onSuccess: () => { + managerSpaces.refetch(); + alert(MESSAGE.MANAGER_SPACE.SPACE_SETTING_UPDATED); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.EDIT_UNEXPECTED_ERROR); + }, + }); + + const deleteSpace = useMutation(deleteManagerSpace, { + onSuccess: () => { + setSelectedSpaceId(null); + setMode(Mode.Default); + + managerSpaces.refetch(); + alert(MESSAGE.MANAGER_SPACE.SPACE_DELETED); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_UNEXPECTED_ERROR); + }, + }); + + const handleCreateSpace = (data: Omit) => + createSpace.mutate({ mapId: Number(mapId), ...data }); + + const handleUpdateSpace = (data: Omit) => + updateSpace.mutate({ mapId: Number(mapId), ...data }); + + const handleDeleteSpace = (data: Omit) => + deleteSpace.mutate({ mapId: Number(mapId), ...data }); + + const handleAddSpace = () => { + setMode(Mode.Rect); + setSelectedSpaceId(null); + }; + + useEffect(() => { + if (selectedSpaceId === null) return; + + setMode(Mode.Form); + }, [selectedSpaceId]); + + return ( + <> +
+ + + + + + + + {isDrawingMode && } + + + + + + + + + + + + {mode === Mode.Form || isDrawingMode ? ( +
+ ) : ( + 공간을 선택해주세요 + )} + + + + + + + ); +}; + +export default ManagerSpaceEditor; diff --git a/frontend/src/pages/ManagerSpaceEditor/data.ts b/frontend/src/pages/ManagerSpaceEditor/data.ts new file mode 100644 index 000000000..5e488c4a4 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/data.ts @@ -0,0 +1,62 @@ +import PALETTE from 'constants/palette'; +import { Area } from 'types/common'; +import { SpaceEditorMode } from 'types/editor'; +import { formatDate, formatTimeWithSecond } from 'utils/datetime'; + +export interface SpaceFormValue { + name: string; + color: string; + availableStartTime: string; + availableEndTime: string; + reservationTimeUnit: string | number; + reservationMinimumTimeUnit: string | number; + reservationMaximumTimeUnit: string | number; + reservationEnable: boolean; + enabledWeekdays: { + monday: boolean; + tuesday: boolean; + wednesday: boolean; + thursday: boolean; + friday: boolean; + saturday: boolean; + sunday: boolean; + }; + area: Area | null; +} + +const today = formatDate(new Date()); + +export const initialSpaceFormValue: Omit = { + reservationEnable: true, + name: '', + color: PALETTE.RED[500], + availableStartTime: formatTimeWithSecond(new Date(`${today}T07:00:00`)), + availableEndTime: formatTimeWithSecond(new Date(`${today}T23:00:00`)), + reservationTimeUnit: '10', + reservationMinimumTimeUnit: '10', + reservationMaximumTimeUnit: '1440', +}; + +export const initialEnabledWeekdays: SpaceFormValue['enabledWeekdays'] = { + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: true, + sunday: true, +}; + +export const colorSelectOptions = [ + PALETTE.RED[500], + PALETTE.ORANGE[500], + PALETTE.YELLOW[500], + PALETTE.GREEN[500], + PALETTE.BLUE[300], + PALETTE.BLUE[900], + PALETTE.PURPLE[500], +]; + +export const timeUnits = ['5', '10', '30', '60']; + +export const drawingModes = [SpaceEditorMode.Rect, SpaceEditorMode.Polygon]; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useBindKeyPress.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useBindKeyPress.ts new file mode 100644 index 000000000..23e8da785 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useBindKeyPress.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from 'react'; + +const useBindKeyPress = (): { pressedKey: string } => { + const [pressedKey, setPressedKey] = useState(''); + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + setPressedKey(event.key); + }, []); + + const handleKeyUp = useCallback(() => { + setPressedKey(''); + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); + }; + }, [handleKeyDown, handleKeyUp]); + + return { pressedKey }; +}; + +export default useBindKeyPress; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardCoordinate.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardCoordinate.ts new file mode 100644 index 000000000..32230b637 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardCoordinate.ts @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { EDITOR } from 'constants/editor'; +import { Coordinate, EditorBoard } from 'types/common'; + +const useBoardCoordinate = ( + boardStatus: EditorBoard +): { + coordinate: Coordinate; + stickyCoordinate: Coordinate; + onMouseMove: (event: React.MouseEvent) => void; +} => { + const [coordinate, setCoordinate] = useState({ x: 0, y: 0 }); + const stickyCoordinate: Coordinate = { + x: Math.floor(coordinate.x / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + y: Math.floor(coordinate.y / EDITOR.GRID_SIZE) * EDITOR.GRID_SIZE, + }; + + const getSVGCoordinate = (event: React.MouseEvent) => { + const svg = (event.nativeEvent.target as SVGElement)?.ownerSVGElement; + if (!svg) return { x: -1, y: -1 }; + + let point = svg.createSVGPoint(); + + point.x = event.nativeEvent.clientX; + point.y = event.nativeEvent.clientY; + point = point.matrixTransform(svg.getScreenCTM()?.inverse()); + + const x = (point.x - boardStatus.x) * (1 / boardStatus.scale); + const y = (point.y - boardStatus.y) * (1 / boardStatus.scale); + + return { x, y }; + }; + + const onMouseMove = (event: React.MouseEvent) => { + const { x, y } = getSVGCoordinate(event); + setCoordinate({ x, y }); + }; + + return { coordinate, stickyCoordinate, onMouseMove }; +}; + +export default useBoardCoordinate; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardMove.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardMove.ts new file mode 100644 index 000000000..59bf4407f --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardMove.ts @@ -0,0 +1,59 @@ +import { Dispatch, MouseEventHandler, SetStateAction, useState } from 'react'; +import { Coordinate, EditorBoard } from 'types/common'; + +const useBoardMove = ( + boardState: [EditorBoard, Dispatch>], + moveMode: boolean +): { + moving: boolean; + onMouseOut: () => void; + onDragStart: (event: React.MouseEvent) => void; + onDrag: (event: React.MouseEvent) => void; + onDragEnd: () => void; +} => { + const [board, setBoard] = boardState; + + const [moving, setMoving] = useState(false); + const [dragOffset, setDragOffset] = useState(null); + + const onMouseOut = () => { + setMoving(false); + }; + + const onDragStart: MouseEventHandler = (event) => { + if (!moveMode) return; + + const dragOffsetX = event.nativeEvent.offsetX - board.x; + const dragOffsetY = event.nativeEvent.offsetY - board.y; + + setMoving(true); + setDragOffset({ x: dragOffsetX, y: dragOffsetY }); + }; + + const onDrag: MouseEventHandler = (event) => { + if (!moveMode || !moving || dragOffset === null) return; + + const { offsetX, offsetY } = event.nativeEvent; + + setBoard((prevState) => ({ + ...prevState, + x: offsetX - dragOffset.x, + y: offsetY - dragOffset.y, + })); + }; + + const onDragEnd = () => { + setMoving(false); + setDragOffset(null); + }; + + return { + moving, + onMouseOut, + onDragStart, + onDrag, + onDragEnd, + }; +}; + +export default useBoardMove; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardStatus.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardStatus.ts new file mode 100644 index 000000000..ec1b8442a --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardStatus.ts @@ -0,0 +1,33 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { EditorBoard } from 'types/common'; +import { BOARD } from '../../../constants/editor'; + +interface Props { + width: number; + height: number; +} + +const useBoardStatus = ({ + width = BOARD.DEFAULT_WIDTH, + height = BOARD.DEFAULT_HEIGHT, +}: Props): [EditorBoard, Dispatch>] => { + const [boardStatus, setBoardStatus] = useState({ + scale: 1, + x: 0, + y: 0, + width, + height, + }); + + useEffect(() => { + setBoardStatus((prevStatus) => ({ + ...prevStatus, + width: Number(width), + height: Number(height), + })); + }, [width, height]); + + return [boardStatus, setBoardStatus]; +}; + +export default useBoardStatus; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardZoom.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardZoom.ts new file mode 100644 index 000000000..656f97971 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useBoardZoom.ts @@ -0,0 +1,51 @@ +import { EDITOR } from 'constants/editor'; +import { EditorBoard } from 'types/common'; + +const useBoardZoom = ( + boardState: [EditorBoard, React.Dispatch>] +): { + onWheel: (event: React.WheelEvent) => void; +} => { + const zoomBoard = ({ offsetX, offsetY, deltaY }: WheelEvent) => { + const [, setBoard] = boardState; + + setBoard((prevStatus) => { + const { scale, x, y, width, height } = prevStatus; + + const nextScale = scale - deltaY * EDITOR.SCALE_DELTA; + + if (nextScale <= EDITOR.MIN_SCALE || nextScale >= EDITOR.MAX_SCALE) { + return { + ...prevStatus, + scale: prevStatus.scale, + }; + } + + const cursorX = (offsetX - x) / (width * scale); + const cursorY = (offsetY - y) / (height * scale); + + const widthDiff = Math.abs(width * nextScale - width * scale) * cursorX; + const heightDiff = Math.abs(height * nextScale - height * scale) * cursorY; + + const nextX = nextScale > scale ? x - widthDiff : x + widthDiff; + const nextY = nextScale > scale ? y - heightDiff : y + heightDiff; + + return { + ...prevStatus, + x: nextX, + y: nextY, + scale: nextScale, + }; + }); + }; + + const onWheel = (event: React.WheelEvent) => { + zoomBoard(event.nativeEvent); + }; + + return { + onWheel, + }; +}; + +export default useBoardZoom; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useDrawingRect.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useDrawingRect.ts new file mode 100644 index 000000000..0ab517f99 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useDrawingRect.ts @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { EDITOR } from 'constants/editor'; +import { Area, Coordinate } from 'types/common'; +import { DrawingAreaShape } from 'types/editor'; + +const useDrawingRect = ( + coordinate: Coordinate +): { + rect: Area | null; + startDrawingRect: () => void; + updateRect: () => void; + endDrawingRect: () => void; +} => { + const [drawingStartCoordinate, setDrawingStartCoordinate] = useState(null); + const [rect, setRect] = useState(null); + + const getRect = (): Area | null => { + if (!drawingStartCoordinate) return null; + + const [startX, endX] = + drawingStartCoordinate.x > coordinate.x + ? [coordinate.x, drawingStartCoordinate.x] + : [drawingStartCoordinate.x, coordinate.x]; + const [startY, endY] = + drawingStartCoordinate.y > coordinate.y + ? [coordinate.y, drawingStartCoordinate.y] + : [drawingStartCoordinate.y, coordinate.y]; + + const width = Math.abs(startX - endX) + EDITOR.GRID_SIZE; + const height = Math.abs(startY - endY) + EDITOR.GRID_SIZE; + + return { + shape: DrawingAreaShape.Rect, + x: startX, + y: startY, + width, + height, + }; + }; + + const startDrawingRect = () => { + setDrawingStartCoordinate(coordinate); + }; + + const updateRect = () => { + setRect(getRect()); + }; + + const endDrawingRect = () => { + setDrawingStartCoordinate(null); + setRect(null); + }; + + return { rect, startDrawingRect, updateRect, endDrawingRect }; +}; + +export default useDrawingRect; diff --git a/frontend/src/pages/ManagerSpaceEditor/hooks/useFormContext.ts b/frontend/src/pages/ManagerSpaceEditor/hooks/useFormContext.ts new file mode 100644 index 000000000..9139513ba --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/hooks/useFormContext.ts @@ -0,0 +1,13 @@ +import React, { useContext } from 'react'; + +const useFormContext = (context: React.Context): T => { + const contextData = useContext(context); + + if (contextData === null) { + throw new Error('context가 존재하지 않습니다.'); + } + + return contextData; +}; + +export default useFormContext; diff --git a/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx b/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx new file mode 100644 index 000000000..74eca0ace --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx @@ -0,0 +1,145 @@ +import React, { createContext, Dispatch, ReactNode, SetStateAction, useState } from 'react'; +import useInputs from 'hooks/useInputs'; +import { Area, ManagerSpace, ManagerSpaceAPI } from 'types/common'; +import { WithOptional } from 'types/util'; +import { formatDate, formatTimeWithSecond } from 'utils/datetime'; +import { initialEnabledWeekdays, initialSpaceFormValue, SpaceFormValue } from '../data'; + +interface Props { + children: ReactNode; +} + +export interface SpaceProviderValue { + values: SpaceFormValue; + onChange: (event: React.ChangeEvent) => void; + resetForm: () => void; + updateArea: (nextArea: Area) => void; + updateWithSpace: (space: ManagerSpace) => void; + setValues: (nextValue: SpaceFormValue) => void; + getRequestValues: () => { + space: WithOptional; + }; + selectedPresetId: number | null; + setSelectedPresetId: Dispatch>; +} + +export const SpaceFormContext = createContext(null); +const weekdays = Object.keys(initialEnabledWeekdays); + +const SpaceFormProvider = ({ children }: Props): JSX.Element => { + const [spaceFormValue, onChangeSpaceFormValues, setSpaceFormValues] = + useInputs(initialSpaceFormValue); + const [enabledWeekdays, onChangeEnabledWeekdays, setEnabledWeekdays] = + useInputs(initialEnabledWeekdays); + const [area, setArea] = useState(null); + const [selectedPresetId, setSelectedPresetId] = useState(null); + + const values = { ...spaceFormValue, enabledWeekdays, area }; + + const setValues = (values: SpaceFormValue) => { + setEnabledWeekdays({ ...values.enabledWeekdays }); + setArea(values.area === null ? null : { ...values.area }); + + const nextValues = { ...values }; + + delete (nextValues as WithOptional).enabledWeekdays; + delete (nextValues as WithOptional).area; + + setSpaceFormValues(nextValues); + }; + + const updateWithSpace = (space: ManagerSpace) => { + const { name, color, area, settings } = space; + + const enableWeekdays = settings.enabledDayOfWeek?.split(',') ?? []; + const nextEnableWeekdays: { [key: string]: boolean } = {}; + Object.keys(values.enabledWeekdays).forEach( + (weekday) => (nextEnableWeekdays[weekday] = enableWeekdays.includes(weekday)) + ); + + setValues({ + name, + color, + ...settings, + enabledWeekdays: nextEnableWeekdays as SpaceFormValue['enabledWeekdays'], + area, + }); + }; + + const updateArea = (nextArea: Area) => { + setArea(nextArea); + setSpaceFormValues(initialSpaceFormValue); + setEnabledWeekdays(initialEnabledWeekdays); + }; + + const getRequestValues = () => { + const todayDate = formatDate(new Date()); + + const availableStartTime = formatTimeWithSecond( + new Date(`${todayDate}T${values.availableStartTime}`) + ); + const availableEndTime = formatTimeWithSecond( + new Date(`${todayDate}T${values.availableEndTime}`) + ); + + const enabledDayOfWeek = Object.keys(values.enabledWeekdays) + .filter( + (weekday) => values.enabledWeekdays[weekday as keyof SpaceFormValue['enabledWeekdays']] + ) + .join(); + + return { + space: { + name: values.name, + color: values.color, + description: values.name, + area: JSON.stringify(values.area), + settings: { + availableStartTime, + availableEndTime, + reservationTimeUnit: Number(values.reservationTimeUnit), + reservationMinimumTimeUnit: Number(values.reservationMinimumTimeUnit), + reservationMaximumTimeUnit: Number(values.reservationMaximumTimeUnit), + reservationEnable: values.reservationEnable, + enabledDayOfWeek, + }, + }, + }; + }; + + const onChange = (event: React.ChangeEvent) => { + if (selectedPresetId !== null) setSelectedPresetId(null); + + if (weekdays.includes(event.target.name)) { + onChangeEnabledWeekdays(event); + + return; + } + + onChangeSpaceFormValues(event); + }; + + const resetForm = () => { + setValues({ ...initialSpaceFormValue, enabledWeekdays: initialEnabledWeekdays, area: null }); + }; + + return ( + + {children} + + ); +}; + +export default SpaceFormProvider; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Board.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/Board.styles.ts new file mode 100644 index 000000000..88b94304f --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Board.styles.ts @@ -0,0 +1,29 @@ +import styled from 'styled-components'; + +interface BoardContainerProps { + isDraggable?: boolean; + isDragging?: boolean; +} + +export const Editor = styled.div` + flex: 1; + height: 100%; +`; + +export const BoardContainer = styled.svg` + cursor: ${({ isDraggable, isDragging }) => { + if (isDraggable) { + if (isDragging) return 'grabbing'; + else return 'grab'; + } + return 'default'; + }}; +`; + +export const BoardContainerBackground = styled.rect``; + +export const Board = styled.svg``; + +export const BoardGroup = styled.g``; + +export const BoardBackground = styled.rect``; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Board.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Board.tsx new file mode 100644 index 000000000..cdd45ba80 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Board.tsx @@ -0,0 +1,74 @@ +import { Dispatch, ReactNode, SetStateAction, SVGAttributes, useEffect, useRef } from 'react'; +import PALETTE from 'constants/palette'; +import { EditorBoard } from 'types/common'; +import useBoardMove from '../hooks/useBoardMove'; +import useBoardZoom from '../hooks/useBoardZoom'; +import * as Styled from './Board.styles'; +import GridPattern from './GridPattern'; + +interface Props extends SVGAttributes { + movable: boolean; + boardState: [EditorBoard, Dispatch>]; + children: ReactNode; +} + +const Board = ({ movable, boardState, children, ...props }: Props): JSX.Element => { + const editorRef = useRef(null); + + const [board, setBoard] = boardState; + + const { onWheel } = useBoardZoom(boardState); + const { moving, onMouseOut, onDragStart, onDrag, onDragEnd } = useBoardMove(boardState, movable); + + useEffect(() => { + const editorWidth = editorRef.current ? editorRef.current.offsetWidth : 0; + const editorHeight = editorRef.current ? editorRef.current.offsetHeight : 0; + + setBoard((prevState) => ({ + ...prevState, + x: (editorWidth - board.width) / 2, + y: (editorHeight - board.height) / 2, + })); + }, [board.width, board.height, setBoard]); + + return ( + + + + + + + + + + + + + {children} + + + + + ); +}; + +export default Board; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardCursorRect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/BoardCursorRect.tsx new file mode 100644 index 000000000..816fbf25c --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardCursorRect.tsx @@ -0,0 +1,18 @@ +import PALETTE from 'constants/palette'; +import { Color, Coordinate } from 'types/common'; + +interface Props { + coordinate: Coordinate; + size: number; + color?: Color; +} + +const BoardCursorRect = ({ + coordinate, + size, + color = PALETTE.OPACITY_BLACK[100], +}: Props): JSX.Element => { + return ; +}; + +export default BoardCursorRect; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardMapElement.tsx b/frontend/src/pages/ManagerSpaceEditor/units/BoardMapElement.tsx new file mode 100644 index 000000000..2f57c43ef --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardMapElement.tsx @@ -0,0 +1,36 @@ +import { EDITOR } from 'constants/editor'; +import { MapElement as MapElementData } from 'types/common'; +import { MapElementType } from 'types/editor'; + +interface Props { + mapElement: MapElementData; +} + +const BoardMapElement = ({ mapElement }: Props): JSX.Element => { + if (mapElement.type === MapElementType.Polyline) { + return ( + + ); + } + + return ( + + ); +}; + +export default BoardMapElement; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.styles.ts new file mode 100644 index 000000000..30d8d308a --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.styles.ts @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +interface SpaceRectProps { + disabled: boolean; + selected: boolean; +} + +export const SpaceRect = styled.rect` + opacity: ${({ selected }) => (selected ? '0.5' : '0.3')}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + + &:hover { + ${({ disabled }) => (disabled ? '' : 'opacity: 0.2;')} + } +`; + +export const SpaceText = styled.text` + dominant-baseline: middle; + text-anchor: middle; + fill: ${({ theme }) => theme.black[700]}; + font-size: 1rem; + pointer-events: none; + user-select: none; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.tsx b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.tsx new file mode 100644 index 000000000..8fa334f97 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/BoardSpace.tsx @@ -0,0 +1,34 @@ +import { ManagerSpace } from 'types/common'; +import { WithOptional } from 'types/util'; +import * as Styled from './BoardSpace.styles'; + +interface Props { + space: WithOptional; + drawing: boolean; + selected: boolean; + onClick?: () => void; +} + +const BoardSpace = ({ space, drawing, selected, onClick }: Props): JSX.Element => { + const { color, area, name } = space; + + return ( + + + + {name} + + + ); +}; + +export default BoardSpace; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/ColorDot.ts b/frontend/src/pages/ManagerSpaceEditor/units/ColorDot.ts new file mode 100644 index 000000000..d73715c03 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/ColorDot.ts @@ -0,0 +1,27 @@ +import styled, { css } from 'styled-components'; +import { Color } from 'types/common'; + +interface ColorDotProps { + color: Color; + size: 'medium' | 'large'; +} + +const colorDotSizeCSS = { + medium: css` + width: 1rem; + height: 1rem; + `, + large: css` + width: 1.5rem; + height: 1.5rem; + `, +}; + +const ColorDot = styled.span` + display: inline-block; + background-color: ${({ color }) => color}; + border-radius: 50%; + ${({ size }) => colorDotSizeCSS[size]}; +`; + +export default ColorDot; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Editor.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Editor.tsx new file mode 100644 index 000000000..fdae8a6d3 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Editor.tsx @@ -0,0 +1,146 @@ +import { + Dispatch, + MouseEventHandler, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { EDITOR, KEY } from 'constants/editor'; +import PALETTE from 'constants/palette'; +import { Area, EditorBoard, ManagerSpace, MapElement } from 'types/common'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import { drawingModes } from '../data'; +import useBindKeyPress from '../hooks/useBindKeyPress'; +import useBoardCoordinate from '../hooks/useBoardCoordinate'; +import useDrawingRect from '../hooks/useDrawingRect'; +import useFormContext from '../hooks/useFormContext'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import Board from './Board'; +import BoardCursorRect from './BoardCursorRect'; +import BoardMapElement from './BoardMapElement'; +import BoardSpace from './BoardSpace'; + +interface Props { + modeState: [Mode, Dispatch>]; + boardState: [EditorBoard, Dispatch>]; + selectedSpaceIdState: [number | null, Dispatch>]; + mapElements: MapElement[]; + spaces: ManagerSpace[]; +} + +const Editor = ({ + modeState, + boardState, + selectedSpaceIdState, + mapElements, + spaces, +}: Props): JSX.Element => { + const [board] = boardState; + const [mode, setMode] = modeState; + const [selectedSpaceId, setSelectedSpaceId] = selectedSpaceIdState; + + const { pressedKey } = useBindKeyPress(); + const [movable, setMovable] = useState(pressedKey === KEY.SPACE); + + const { values, updateWithSpace, updateArea } = useFormContext(SpaceFormContext); + const { stickyCoordinate, onMouseMove: updateCoordinate } = useBoardCoordinate(board); + + const { rect, startDrawingRect, updateRect, endDrawingRect } = useDrawingRect(stickyCoordinate); + const [isDrawing, setIsDrawing] = useState(false); + + const isDrawingMode = useMemo(() => drawingModes.includes(mode) && !movable, [mode, movable]); + + const unSelectedSpaces = useMemo(() => { + if (selectedSpaceId === null) return spaces; + + return spaces.filter(({ id }) => id !== selectedSpaceId); + }, [spaces, selectedSpaceId]); + + const handleClickSpace = useCallback( + (spaceId: number) => { + if (isDrawingMode) return; + + const selectedSpace = spaces.find((space) => space.id === spaceId); + + updateWithSpace(selectedSpace as ManagerSpace); + setSelectedSpaceId(spaceId); + }, + [isDrawingMode, spaces, setSelectedSpaceId, updateWithSpace] + ); + + const handleDrawingStart = useCallback(() => { + if (!isDrawingMode) return; + + setIsDrawing(true); + + if (mode === Mode.Rect) startDrawingRect(); + }, [isDrawingMode, setIsDrawing, mode, startDrawingRect]); + + const handleMouseMove: MouseEventHandler = useCallback( + (event) => { + updateCoordinate(event); + + if (!isDrawingMode || !isDrawing) return; + + if (mode === Mode.Rect) { + updateRect(); + updateArea(rect as Area); + } + }, + [isDrawing, isDrawingMode, mode, rect, updateArea, updateRect, updateCoordinate] + ); + + const handleDrawingEnd = useCallback(() => { + if (!isDrawingMode || !isDrawing) return; + + setMode(Mode.Form); + endDrawingRect(); + setIsDrawing(false); + }, [isDrawing, isDrawingMode, setMode, endDrawingRect]); + + useEffect(() => { + setMovable(pressedKey === KEY.SPACE); + }, [movable, pressedKey]); + + return ( + + {values.area && ( + + )} + + {isDrawingMode && } + + {unSelectedSpaces?.map((space, index) => ( + handleClickSpace(space.id)} + /> + ))} + + {mapElements?.map((element, index) => ( + + ))} + + ); +}; + +export default Editor; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.styles.ts new file mode 100644 index 000000000..c2743ff39 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.styles.ts @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const MapName = styled.h2` + font-size: 1.5rem; +`; + +export const ButtonContainer = styled.div``; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.tsx b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.tsx new file mode 100644 index 000000000..0f87e9ff2 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/EditorHeader.tsx @@ -0,0 +1,24 @@ +import { Link } from 'react-router-dom'; +import Button from 'components/Button/Button'; +import PATH from 'constants/path'; +import * as Styled from './EditorHeader.styles'; + +interface Props { + mapName: string; +} + +// TODO: Link태그 내 Button 태그 제거 +const EditorHeader = ({ mapName }: Props): JSX.Element => { + return ( + + {mapName} + + + + + + + ); +}; + +export default EditorHeader; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Form.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/Form.styles.ts new file mode 100644 index 000000000..1260b114d --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Form.styles.ts @@ -0,0 +1,119 @@ +import styled from 'styled-components'; +import Button from 'components/Button/Button'; + +interface FormContainerProps { + disabled: boolean; +} + +export const Form = styled.form` + padding: 2rem 1.5rem; + overflow-y: ${({ disabled }) => (disabled ? 'hidden' : 'auto')}; + flex: 1; + display: flex; + flex-direction: column; + gap: 2rem; +`; + +export const Section = styled.section``; + +export const Title = styled.h3` + font-size: 1.25rem; +`; + +export const TitleContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +`; + +export const ContentsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1.375rem; +`; + +export const Row = styled.div``; + +export const ColorSelect = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const ColorInputLabel = styled.label` + display: inline-block; + padding: 0.5rem; + border: 1px solid ${({ theme }) => theme.gray[500]}; + border-radius: 0.125rem; + cursor: pointer; +`; + +export const ColorInput = styled.input` + width: 0; + height: 0; + opacity: 0; + padding: 0; +`; + +export const ColorDotButton = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; +`; + +export const InputWrapper = styled.div` + display: flex; + gap: 1rem; + + label { + flex: 1; + } +`; + +export const InputMessage = styled.p` + font-size: 0.75rem; + margin: 0.25rem 0.5rem; + color: ${({ theme }) => theme.gray[400]}; +`; + +export const Label = styled.div` + font-size: 0.75rem; + color: ${({ theme }) => theme.gray[500]}; +`; + +export const Fieldset = styled.div` + position: relative; + border: 1px solid ${({ theme }) => theme.gray[500]}; + border-radius: 0.125rem; + padding: 1rem 0.75rem; + margin-top: 0.375rem; + + ${Label} { + position: absolute; + top: -0.375rem; + left: 0.75rem; + padding: 0 0.25rem; + background-color: ${({ theme }) => theme.white}; + } +`; + +export const FormSubmitContainer = styled.div` + display: flex; + justify-content: flex-end; +`; + +export const DeleteButton = styled(Button)` + color: ${({ theme }) => theme.red[900]}; + display: inline-flex; + align-items: center; + gap: 0.25rem; + + svg { + width: 1rem; + height: 1rem; + vertical-align: middle; + fill: ${({ theme }) => theme.red[900]}; + } +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx new file mode 100644 index 000000000..8a4e1ba43 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx @@ -0,0 +1,279 @@ +import { Dispatch, FormEventHandler, SetStateAction, useEffect, useRef } from 'react'; +import { + DeleteManagerSpaceParams, + PostManagerSpaceParams, + PutManagerSpaceParams, +} from 'api/managerSpace'; +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import { ReactComponent as PaletteIcon } from 'assets/svg/palette.svg'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import Toggle from 'components/Toggle/Toggle'; +import MESSAGE from 'constants/message'; +import { Area, Color, ManagerSpace, MapElement } from 'types/common'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import { generateSvg, MapSvgData } from 'utils/generateSvg'; +import { colorSelectOptions, timeUnits } from '../data'; +import useFormContext from '../hooks/useFormContext'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import ColorDot from './ColorDot'; +import * as Styled from './Form.styles'; +import FormTimeUnitSelect from './FormTimeUnitSelect'; +import FormWeekdaySelect from './FormWeekdaySelect'; +import Preset from './Preset'; + +interface Props { + modeState: [Mode, Dispatch>]; + mapData: { width: number; height: number; mapElements: MapElement[] }; + spaces: ManagerSpace[]; + selectedSpaceId: number | null; + disabled: boolean; + onCreateSpace: (data: Omit) => void; + onUpdateSpace: (data: Omit) => void; + onDeleteSpace: (data: Omit) => void; +} + +const Form = ({ + modeState, + mapData, + spaces, + selectedSpaceId, + disabled, + onCreateSpace, + onUpdateSpace, + onDeleteSpace, +}: Props): JSX.Element => { + const nameInputRef = useRef(null); + + const [mode, setMode] = modeState; + + const { values, onChange, resetForm, setValues, getRequestValues } = + useFormContext(SpaceFormContext); + + const setColor = (color: Color) => { + setValues({ ...values, color }); + }; + + const getSpacesForSvg = (): MapSvgData['spaces'] => { + if (selectedSpaceId === null && values.area) { + return [...spaces, { area: values.area, color: values.color }]; + } + + return spaces.map((space) => + space.id === selectedSpaceId ? { area: values.area as Area, color: values.color } : space + ); + }; + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const mapImageSvg = generateSvg({ ...mapData, spaces: getSpacesForSvg() }); + const valuesForRequest = getRequestValues(); + + if (selectedSpaceId === null) { + onCreateSpace({ + space: { + mapImageSvg, + ...valuesForRequest.space, + settingsRequest: { ...valuesForRequest.space.settings }, + }, + }); + + return; + } + + onUpdateSpace({ + spaceId: selectedSpaceId, + space: { + mapImageSvg, + ...valuesForRequest.space, + settingsRequest: { ...valuesForRequest.space.settings }, + }, + }); + }; + + const handleDelete = () => { + if (selectedSpaceId === null) return; + if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_SPACE_CONFIRM)) return; + + const filteredSpaces = spaces.filter(({ id }) => id !== selectedSpaceId); + const mapImageSvg = generateSvg({ ...mapData, spaces: filteredSpaces }); + + onDeleteSpace({ + spaceId: selectedSpaceId, + mapImageSvg, + }); + + resetForm(); + }; + + const handleCancel = () => { + resetForm(); + + setMode(Mode.Default); + }; + + useEffect(() => { + if (mode !== Mode.Form) return; + + nameInputRef.current?.focus(); + }, [mode, selectedSpaceId]); + + return ( + + + + 공간 설정 + + + + + + } + label="공간 이름" + value={values.name} + name="name" + onChange={onChange} + ref={nameInputRef} + required + /> + + + + + + + + + {colorSelectOptions.map((color) => ( + setColor(color)}> + + + ))} + + + + + + + + 예약 조건 + + + + + + + + + + + + + 예약이 열릴 시간과 닫힐 시간을 설정해주세요. + + + + + 예약 시간 단위 + + + 예약 시간의 단위를 설정해주세요. + + + + + + + + + 예약 가능한 최소 시간과 최대 시간을 설정해주세요. + + + + + + 예약 가능한 요일 + + + + + + {selectedSpaceId ? ( + + + + 공간 삭제 + + + + ) : ( + + + + + )} + + + + + ); +}; + +export default Form; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.styles.ts new file mode 100644 index 000000000..7feddffac --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.styles.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + justify-content: space-between; +`; + +export const Label = styled.label``; + +export const Input = styled.input``; + +export const LabelText = styled.span``; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.tsx new file mode 100644 index 000000000..8e6fd8d4a --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormTimeUnitSelect.tsx @@ -0,0 +1,31 @@ +import { ChangeEventHandler } from 'react'; +import * as Styled from './FormTimeUnitSelect.styles'; + +interface Props { + timeUnits: string[]; + name: string; + selectedValue: string; + onChange: ChangeEventHandler; +} + +const FormTimeUnitSelect = ({ timeUnits, name, onChange, selectedValue }: Props): JSX.Element => { + return ( + + {timeUnits.map((timeUnit) => ( + + + {timeUnit}분 + + ))} + + ); +}; + +export default FormTimeUnitSelect; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts new file mode 100644 index 000000000..363c9f2cc --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts @@ -0,0 +1,32 @@ +import styled, { css } from 'styled-components'; + +interface WeekdayProps { + value?: 'Saturday' | 'Sunday'; +} + +export const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const Label = styled.label` + display: inline-flex; + flex-direction: column; + align-items: center; + cursor: pointer; +`; + +const weekdayColorCSS = { + default: css``, + Saturday: css` + color: ${({ theme }) => theme.blue[900]}; + `, + Sunday: css` + color: ${({ theme }) => theme.red[500]}; + `, +}; + +export const DisplayName = styled.span` + ${({ value }) => weekdayColorCSS[value ?? 'default']}; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx new file mode 100644 index 000000000..4022346c6 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx @@ -0,0 +1,73 @@ +import { ChangeEventHandler } from 'react'; +import * as Styled from './FormWeekdaySelect.styles'; + +interface Props { + onChange: ChangeEventHandler; + enabledWeekdays: { [key: string]: boolean }; +} + +interface Weekday { + displayName: string; + inputName: T; +} + +const weekday: { [key in keyof Props['enabledWeekdays']]: Weekday } = { + monday: { + displayName: '월', + inputName: 'monday', + }, + tuesday: { + displayName: '화', + inputName: 'tuesday', + }, + wednesday: { + displayName: '수', + inputName: 'wednesday', + }, + thursday: { + displayName: '목', + inputName: 'thursday', + }, + friday: { + displayName: '금', + inputName: 'friday', + }, + saturday: { + displayName: '토', + inputName: 'saturday', + }, + sunday: { + displayName: '일', + inputName: 'sunday', + }, +}; + +const displayOrder = [ + weekday.monday, + weekday.tuesday, + weekday.wednesday, + weekday.thursday, + weekday.friday, + weekday.saturday, + weekday.sunday, +]; + +const FormWeekdaySelect = ({ onChange, enabledWeekdays }: Props): JSX.Element => { + return ( + + {displayOrder.map(({ displayName, inputName }) => ( + + {displayName} + + + ))} + + ); +}; + +export default FormWeekdaySelect; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/GridPattern.tsx b/frontend/src/pages/ManagerSpaceEditor/units/GridPattern.tsx new file mode 100644 index 000000000..a429b1101 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/GridPattern.tsx @@ -0,0 +1,51 @@ +import { EDITOR } from 'constants/editor'; +import PALETTE from 'constants/palette'; + +interface BoardSize { + width: number; + height: number; +} + +const GridPatternDefs = () => ( + + + + + + + + + +); + +const GridPattern = ({ width, height }: BoardSize): JSX.Element => ( + +); + +GridPattern.Defs = GridPatternDefs; + +export default GridPattern; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Preset.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/Preset.styles.ts new file mode 100644 index 000000000..3cc138424 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Preset.styles.ts @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +export const PresetSelect = styled.div` + display: flex; + gap: 0.5rem; +`; + +export const PresetSelectWrapper = styled.div` + flex: 1; +`; + +export const PresetOption = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const PresetName = styled.span` + display: block; + flex: 1; +`; + +export const PresetNameFormControl = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 1rem; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx new file mode 100644 index 000000000..bc2e227dd --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx @@ -0,0 +1,157 @@ +import { AxiosError } from 'axios'; +import { FormEventHandler, useMemo, useState } from 'react'; +import { useMutation } from 'react-query'; +import { deletePreset, postPreset } from 'api/presets'; +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import Button from 'components/Button/Button'; +import IconButton from 'components/IconButton/IconButton'; +import Select from 'components/Select/Select'; +import MESSAGE from 'constants/message'; +import useInput from 'hooks/useInput'; +import usePresets from 'hooks/usePreset'; +import { Preset as PresetType } from 'types/common'; +import { ErrorResponse } from 'types/response'; +import { SpaceFormValue } from '../data'; +import useFormContext from '../hooks/useFormContext'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import * as Styled from './Preset.styles'; +import PresetNameModal from './PresetNameModal'; + +interface CreateResponseHeaders { + location: string; +} + +const Preset = (): JSX.Element => { + const { values, setValues, selectedPresetId, setSelectedPresetId, getRequestValues } = + useFormContext(SpaceFormContext); + const [isModalOpen, setIsModalOpen] = useState(false); + const [presetName, onChangePresetName] = useInput(''); + + const getPresets = usePresets(); + const presets = useMemo( + () => getPresets.data?.data?.presets ?? [], + [getPresets.data?.data?.presets] + ); + + const createPreset = useMutation(postPreset, { + onSuccess: (response) => { + const { location } = response.headers as CreateResponseHeaders; + const newPresetId = Number(location.split('/').pop() ?? ''); + + setSelectedPresetId(newPresetId); + setIsModalOpen(false); + getPresets.refetch(); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.ADD_PRESET_UNEXPECTED_ERROR); + }, + }); + + const removePreset = useMutation(deletePreset, { + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.MANAGER_SPACE.DELETE_PRESET_UNEXPECTED_ERROR); + }, + }); + + const handleSelectPreset = (id: number | null) => { + setSelectedPresetId(id); + + if (id === null) return; + + const selectedPreset = presets.find((preset) => preset.id === id) ?? null; + + if (selectedPreset === null) throw new Error(MESSAGE.MANAGER_SPACE.FIND_PRESET_ERROR); + + const { + availableStartTime, + availableEndTime, + reservationTimeUnit, + reservationMinimumTimeUnit, + reservationMaximumTimeUnit, + } = selectedPreset; + + const enabledDayOfWeek = selectedPreset.enabledDayOfWeek?.split(',') ?? []; + const enabledWeekdays: { [key: string]: boolean } = {}; + Object.keys(values.enabledWeekdays).forEach( + (weekday) => (enabledWeekdays[weekday] = enabledDayOfWeek?.includes(weekday)) + ); + + setValues({ + ...values, + availableStartTime, + availableEndTime, + reservationTimeUnit, + reservationMinimumTimeUnit, + reservationMaximumTimeUnit, + enabledWeekdays: enabledWeekdays as SpaceFormValue['enabledWeekdays'], + }); + }; + + const handleAddPreset = () => { + setIsModalOpen(true); + }; + + const handleSubmitPreset: FormEventHandler = (event) => { + event.preventDefault(); + event.stopPropagation(); + + const requestValues = getRequestValues(); + + createPreset.mutate({ name: presetName, settingsRequest: requestValues.space.settings }); + }; + + const handleDeletePreset = (event: React.MouseEvent, id: PresetType['id']) => { + event.stopPropagation(); + + if (!window.confirm(MESSAGE.MANAGER_SPACE.DELETE_PRESET_CONFIRM)) return; + + removePreset.mutate({ id }); + }; + + const presetOptions = presets.map(({ id, name }) => ({ + value: `${id}`, + title: name, + children: ( + + {name} + handleDeletePreset(event, Number(id))} + > + + + + ), + })); + + return ( + <> + + + + + + + + + + ); +}; + +export default PresetNameModal; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.styles.ts new file mode 100644 index 000000000..8a4fc4a33 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.styles.ts @@ -0,0 +1,33 @@ +import styled, { css } from 'styled-components'; +import IconButton from 'components/IconButton/IconButton'; + +interface ToolbarButtonProps { + selected?: boolean; +} + +const primaryIconCSS = css` + svg { + fill: ${({ theme }) => theme.primary[400]}; + } +`; + +export const Container = styled.div` + position: absolute; + top: 0.5rem; + left: 50%; + transform: translateX(-50%); + margin: 0 auto; + padding: 0.5rem 1rem; + background-color: ${({ theme }) => theme.gray[100]}; + border-right: 1px solid ${({ theme }) => theme.gray[300]}; + display: flex; + gap: 1rem; +`; + +export const ToolbarButton = styled(IconButton)` + background-color: ${({ theme, selected }) => (selected ? theme.gray[100] : 'none')}; + border: 1px solid ${({ theme, selected }) => (selected ? theme.gray[400] : 'transparent')}; + border-radius: 0; + box-sizing: content-box; + ${({ selected }) => selected && primaryIconCSS} +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.tsx b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.tsx new file mode 100644 index 000000000..e0c13130a --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/ShapeSelectToolbar.tsx @@ -0,0 +1,37 @@ +import { ReactComponent as CloseIcon } from 'assets/svg/close.svg'; +import { ReactComponent as RectIcon } from 'assets/svg/rect.svg'; +import { SpaceEditorMode as Mode } from 'types/editor'; +import useFormContext from '../hooks/useFormContext'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import * as Styled from './ShapeSelectToolbar.styles'; + +interface Props { + mode: Mode; + setMode: (nextMode: Mode) => void; +} + +const ShapeSelectToolbar = ({ mode, setMode }: Props): JSX.Element => { + const { resetForm } = useFormContext(SpaceFormContext); + + const handleCancel = () => { + resetForm(); + setMode(Mode.Default); + }; + + return ( + + + + + setMode(Mode.Rect)} + > + + + + ); +}; + +export default ShapeSelectToolbar; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/SpaceAddButton.tsx b/frontend/src/pages/ManagerSpaceEditor/units/SpaceAddButton.tsx new file mode 100644 index 000000000..3c131172a --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/SpaceAddButton.tsx @@ -0,0 +1,25 @@ +import { ReactComponent as PlusSmallIcon } from 'assets/svg/plus-small.svg'; +import Button from 'components/Button/Button'; +import useFormContext from '../hooks/useFormContext'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; + +interface Props { + onClick: () => void; +} + +const SpaceAddButton = ({ onClick }: Props): JSX.Element => { + const { resetForm } = useFormContext(SpaceFormContext); + + const handleAddSpace = () => { + onClick(); + resetForm(); + }; + + return ( + + ); +}; + +export default SpaceAddButton; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.styles.ts new file mode 100644 index 000000000..ac27d0b18 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.styles.ts @@ -0,0 +1,22 @@ +import styled from 'styled-components'; + +export const SpaceSelect = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.gray[300]}; + padding: 1.5rem; + position: relative; +`; + +export const Title = styled.h3` + font-size: 1.25rem; + margin-bottom: 1.5rem; +`; + +export const SpaceSelectWrapper = styled.div` + margin-bottom: 0.5rem; +`; + +export const SpaceOption = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; +`; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.tsx new file mode 100644 index 000000000..a56261ab9 --- /dev/null +++ b/frontend/src/pages/ManagerSpaceEditor/units/SpaceSelect.tsx @@ -0,0 +1,59 @@ +import { Dispatch, ReactNode, SetStateAction, useMemo } from 'react'; +import Select from 'components/Select/Select'; +import { ManagerSpace } from 'types/common'; +import { sortSpaces } from 'utils/sort'; +import useFormContext from '../hooks/useFormContext'; +import { SpaceFormContext } from '../providers/SpaceFormProvider'; +import ColorDot from './ColorDot'; +import * as Styled from './SpaceSelect.styles'; + +interface Props { + spaces: ManagerSpace[]; + selectedSpaceIdState: [number | null, Dispatch>]; + disabled: boolean; + children: ReactNode; +} + +const SpaceSelect = ({ spaces, selectedSpaceIdState, disabled, children }: Props): JSX.Element => { + const [selectedSpaceId, setSelectedSpaceId] = selectedSpaceIdState; + + const { updateWithSpace } = useFormContext(SpaceFormContext); + + const sortedSpaces = useMemo(() => sortSpaces(spaces), [spaces]); + + const spaceOptions = sortedSpaces.map(({ id, name, color }) => ({ + value: `${id}`, + children: ( + + + {name} + + ), + })); + + const handleChangeSpace = (spaceId: number) => { + const selectedSpace = spaces.find((space) => space.id === spaceId); + + updateWithSpace(selectedSpace as ManagerSpace); + setSelectedSpaceId(spaceId); + }; + + return ( + + 공간 선택 + + - - - - - - - + {selectedSpaceId && map?.mapId && detailOpen && ( + setDetailOpen(false)} + onClickReservation={handleReservation} + onEdit={handleEdit} + onDelete={handleDelete} + /> + )} + + {passwordInputModalOpen && ( + setPasswordInputModalOpen(false)} + > + 예약시 사용하신 비밀번호를 입력해주세요. + +
+ + + + + +
+
+
+ )} ); }; diff --git a/frontend/src/pages/GuestMap/units/ReservationDrawer.styles.ts b/frontend/src/pages/GuestMap/units/ReservationDrawer.styles.ts new file mode 100644 index 000000000..0688ca9b7 --- /dev/null +++ b/frontend/src/pages/GuestMap/units/ReservationDrawer.styles.ts @@ -0,0 +1,46 @@ +import styled from 'styled-components'; +import Button from 'components/Button/Button'; +import ColorDotComponent from 'components/ColorDot/ColorDot'; + +export const ReservationContainer = styled.div` + padding: 0 2rem 2rem; +`; + +export const ReservationList = styled.div` + overflow-y: auto; + & > [role='listitem'] { + border-bottom: 1px solid ${({ theme }) => theme.black[400]}; + + &:last-of-type { + border: 0; + } + } +`; + +export const ReservationButton = styled(Button)` + position: sticky; + bottom: 0; + left: 0; +`; + +export const SpaceTitle = styled.h3` + position: sticky; + top: 0; + font-size: 1.25rem; + text-align: center; + padding: 2rem; + background-color: ${({ theme }) => theme.white}; +`; + +export const ColorDot = styled(ColorDotComponent)` + margin-right: 0.75rem; +`; + +export const Message = styled.p` + padding: 1rem 0; +`; + +export const IconButtonWrapper = styled.div` + display: flex; + gap: 0.5rem; +`; diff --git a/frontend/src/pages/GuestMap/units/ReservationDrawer.tsx b/frontend/src/pages/GuestMap/units/ReservationDrawer.tsx new file mode 100644 index 000000000..ddb789ece --- /dev/null +++ b/frontend/src/pages/GuestMap/units/ReservationDrawer.tsx @@ -0,0 +1,95 @@ +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import { ReactComponent as EditIcon } from 'assets/svg/edit.svg'; +import Drawer from 'components/Drawer/Drawer'; +import IconButton from 'components/IconButton/IconButton'; +import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; +import useGuestReservations from 'hooks/query/useGuestReservations'; +import { Reservation, Space } from 'types/common'; +import { formatDate } from 'utils/datetime'; +import * as Styled from './ReservationDrawer.styles'; + +interface Props { + mapId: number; + space: Space; + date: Date; + open: boolean; + onClose: () => void; + onClickReservation: () => void; + onEdit: (reservation: Reservation) => void; + onDelete: (reservation: Reservation) => void; +} + +const ReservationDrawer = ({ + mapId, + space, + date, + open, + onClose, + onClickReservation, + onEdit, + onDelete, +}: Props): JSX.Element => { + const getReservations = useGuestReservations({ + mapId, + spaceId: space.id, + date: formatDate(date), + }); + + const reservations = getReservations.data?.data?.reservations ?? []; + + return ( + + + + {space.name} + + + {getReservations.isLoadingError && ( + + 예약 목록을 불러오는 데 문제가 생겼어요! +
+ 새로 고침으로 다시 시도해주세요. +
+ )} + {getReservations.isSuccess && reservations?.length === 0 && ( + 오늘의 첫 예약을 잡아보세요! + )} + {getReservations.isSuccess && reservations.length > 0 && ( + + {reservations.map((reservation: Reservation) => ( + + onEdit(reservation)} aria-label="수정"> + + + onDelete(reservation)} + aria-label="삭제" + > + + + + } + /> + ))} + + )} +
+ + 예약하기 + +
+ ); +}; + +export default ReservationDrawer; diff --git a/frontend/src/pages/GuestReservation/GuestReservation.tsx b/frontend/src/pages/GuestReservation/GuestReservation.tsx index 42b63630e..5642c2116 100644 --- a/frontend/src/pages/GuestReservation/GuestReservation.tsx +++ b/frontend/src/pages/GuestReservation/GuestReservation.tsx @@ -51,9 +51,12 @@ const GuestReservation = (): JSX.Element => { const addReservation = useMutation(postGuestReservation, { onSuccess: () => { - history.push(HREF.GUEST_MAP(sharingMapId), { - spaceId: space.id, - targetDate: new Date(date), + history.push({ + pathname: HREF.GUEST_MAP(sharingMapId), + state: { + spaceId: space.id, + targetDate: new Date(date), + }, }); }, onError: (error: AxiosError) => { @@ -63,9 +66,12 @@ const GuestReservation = (): JSX.Element => { const updateReservation = useMutation(putGuestReservation, { onSuccess: () => { - history.push(HREF.GUEST_MAP(sharingMapId), { - spaceId: space.id, - targetDate: new Date(date), + history.push({ + pathname: HREF.GUEST_MAP(sharingMapId), + state: { + spaceId: space.id, + targetDate: new Date(date), + }, }); }, diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx index 0713ab7ad..b1debbf2f 100644 --- a/frontend/src/test-utils.tsx +++ b/frontend/src/test-utils.tsx @@ -1,5 +1,5 @@ import { render, RenderOptions, RenderResult } from '@testing-library/react'; -import React from 'react'; +import React, { Suspense } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { BrowserRouter as Router } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; @@ -12,8 +12,10 @@ const AllTheProviders: React.FC = ({ children }): JSX.Element => ( - - {children} + }> + + {children} + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 80a8189e7..e2d715d5d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,7 +5,7 @@ /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, - "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, "lib": ["ESNext", "DOM"] /* Specify library files to be included in the compilation. */, // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ @@ -44,7 +44,7 @@ // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ /* Module Resolution Options */ - // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, "baseUrl": "src" /* Base directory to resolve non-absolute module names. */, // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 3bae6f777..ddf91bd53 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -3,6 +3,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); const webpack = require('webpack'); +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = () => { const isDevelopment = process.env.NODE_ENV !== 'production'; @@ -13,7 +14,7 @@ module.exports = () => { entry: './src/index.tsx', output: { publicPath: '/', - filename: 'bundle.js', + filename: 'bundle.[chunkhash].js', path: path.resolve(__dirname, 'dist'), clean: true, }, @@ -61,6 +62,11 @@ module.exports = () => { }, ], }, + optimization: { + splitChunks: { + chunks: 'all', + }, + }, plugins: [ new HtmlWebpackPlugin({ template: 'public/index.html', @@ -74,6 +80,9 @@ module.exports = () => { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 'process.env.DEPLOY_ENV': JSON.stringify(process.env.DEPLOY_ENV), }), + new BundleAnalyzerPlugin({ + analyzerMode: isDevelopment ? 'server' : 'static', + }), ], }; }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7e5e973bc..2aad4cb3f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1995,6 +1995,11 @@ schema-utils "^2.6.5" source-map "^0.7.3" +"@polka/url@^1.0.0-next.20": + version "1.0.0-next.20" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.20.tgz#111b5db0f501aa89b05076fa31f0ea0e0c292cd3" + integrity sha512-88p7+M0QGxKpmnkfXjS4V26AnoC/eiqZutE8GLdaI5X12NY75bXSdTY9NkmYb2Xyk1O+MmkuO6Frmsj84V6I8Q== + "@popperjs/core@^2.5.4", "@popperjs/core@^2.6.0": version "2.9.2" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353" @@ -3920,6 +3925,11 @@ acorn-walk@^7.1.1, acorn-walk@^7.2.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.0.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + acorn@^6.4.1: version "6.4.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" @@ -3930,6 +3940,11 @@ acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.0.4: + version "8.5.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2" + integrity sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q== + acorn@^8.2.4, acorn@^8.4.1: version "8.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c" @@ -5464,7 +5479,7 @@ commander@^4.1.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== -commander@^6.2.1: +commander@^6.2.0, commander@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== @@ -6321,7 +6336,7 @@ downshift@^6.0.15: prop-types "^15.7.2" react-is "^17.0.2" -duplexer@^0.1.1: +duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== @@ -7795,6 +7810,13 @@ gzip-size@5.1.1: duplexer "^0.1.1" pify "^4.0.1" +gzip-size@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" + integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== + dependencies: + duplexer "^0.1.2" + hamt_plus@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/hamt_plus/-/hamt_plus-1.0.2.tgz#e21c252968c7e33b20f6a1b094cd85787a265601" @@ -10184,7 +10206,7 @@ mime@1.6.0, mime@^1.3.4: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.4.4: +mime@^2.3.1, mime@^2.4.4: version "2.5.2" resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== @@ -10790,6 +10812,11 @@ open@^7.0.2, open@^7.0.3: is-docker "^2.0.0" is-wsl "^2.1.1" +opener@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== + opn@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" @@ -12825,6 +12852,15 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +sirv@^1.0.7: + version "1.0.17" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.17.tgz#86e2c63c612da5a1dace1c16c46f524aaa26ac45" + integrity sha512-qx9go5yraB7ekT7bCMqUHJ5jEaOC/GXBxUWv+jeWnb7WzHUFdcQPGWk7YmAwFBaQBrogpuSqd/azbC2lZRqqmw== + dependencies: + "@polka/url" "^1.0.0-next.20" + mime "^2.3.1" + totalist "^1.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -13751,6 +13787,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +totalist@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" + integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -14435,6 +14476,21 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webpack-bundle-analyzer@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.2.tgz#39898cf6200178240910d629705f0f3493f7d666" + integrity sha512-PIagMYhlEzFfhMYOzs5gFT55DkUdkyrJi/SxJp8EF3YMWhS+T9vvs2EoTetpk5qb6VsCq02eXTlRDOydRhDFAQ== + dependencies: + acorn "^8.0.4" + acorn-walk "^8.0.0" + chalk "^4.1.0" + commander "^6.2.0" + gzip-size "^6.0.0" + lodash "^4.17.20" + opener "^1.5.2" + sirv "^1.0.7" + ws "^7.3.1" + webpack-cli@^4.7.2: version "4.7.2" resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.7.2.tgz#a718db600de6d3906a4357e059ae584a89f4c1a5" @@ -14768,6 +14824,11 @@ ws@^6.2.1: dependencies: async-limiter "~1.0.0" +ws@^7.3.1: + version "7.5.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" + integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== + ws@^7.4.5: version "7.5.3" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.3.tgz#160835b63c7d97bfab418fc1b8a9fced2ac01a74" From 236efaaa6c2cb03f16f839f8f287e256547fb4f0 Mon Sep 17 00:00:00 2001 From: JO YUN HO Date: Thu, 16 Sep 2021 21:36:16 +0900 Subject: [PATCH 16/25] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=84=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EA=B0=80=20=EA=B4=80=EB=A6=AC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20(#554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Button 컴포넌트에 dense 사이즈 속성 추가 * feat: 예약 목록 Panel에 예약 추가하기 버튼 추가 * refactor: unit 컴포넌트 이름 변경 - ReservationForm -> GuestReservationForm * feat: 매니저 예약 페이지 생성 * feat: 공간관리자 예약 페이지 경로 및 라우터 추가 * refactor: managerSpaces 타입 가드 추가 * refactor: 사용하지 않는 요소 제거 * feat: 공간관리자 예약 추가 api 추가 * refactor: 네이밍 변경 * feat: 공간관리자 예약 추가 기능 * refactor: 공간관리자 맵 단위 예약조회 api 이름 변경 - queryManagerReservations -> queryManagerMapReservations * feat: 공간관리자 공간 단위 예약 조회 api 및 hook 작성 * feat: 예약 완료 후 선택한 예약 날짜인 상태로 메인 페이지로 돌아가는 기능 * refactor: 타입 정리 * refactor: 공간관리자 예약, 수정을 하나의 컴포넌트에서 처리하도록 변경 * refactor: 예약 수정, 추가 시, 예약 목록이 일정한 간격을 유지하도록 페이지 스타일 변경 * refactor: 공간관리자 공간 조회 query의 mapId가 null을 받지 못하도록 변경 * refactor: 공간관리자 예약 관련 타입 정리 * refactor: Query 함수의 파라미터 타입에 null이 들어오지 않도록 변경 --- frontend/src/api/guestReservation.ts | 2 +- frontend/src/api/managerReservation.ts | 50 ++++- frontend/src/api/managerSpaces.ts | 5 + .../src/components/Button/Button.styles.ts | 5 +- frontend/src/components/Button/Button.tsx | 2 +- frontend/src/constants/path.ts | 1 + frontend/src/constants/routes.tsx | 11 +- .../hooks/query/useManagerMapReservations.ts | 17 ++ .../src/hooks/query/useManagerReservations.ts | 17 -- .../query/useManagerSpaceReservations.ts | 24 ++ .../GuestReservation/GuestReservation.tsx | 4 +- ...yles.ts => GuestReservationForm.styles.ts} | 0 ...ationForm.tsx => GuestReservationForm.tsx} | 6 +- .../pages/ManagerMain/ManagerMain.styles.ts | 6 + .../src/pages/ManagerMain/ManagerMain.tsx | 59 ++++- .../ManagerReservation.styles.ts | 31 +++ .../ManagerReservation/ManagerReservation.tsx | 157 ++++++++++++++ .../units/ManagerReservationForm.styles.ts} | 19 +- .../units/ManagerReservationForm.tsx | 205 ++++++++++++++++++ .../ManagerReservationEdit.tsx | 205 ------------------ frontend/src/types/response.ts | 5 +- 21 files changed, 557 insertions(+), 274 deletions(-) create mode 100644 frontend/src/hooks/query/useManagerMapReservations.ts delete mode 100644 frontend/src/hooks/query/useManagerReservations.ts create mode 100644 frontend/src/hooks/query/useManagerSpaceReservations.ts rename frontend/src/pages/GuestReservation/units/{ReservationForm.styles.ts => GuestReservationForm.styles.ts} (100%) rename frontend/src/pages/GuestReservation/units/{ReservationForm.tsx => GuestReservationForm.tsx} (97%) create mode 100644 frontend/src/pages/ManagerReservation/ManagerReservation.styles.ts create mode 100644 frontend/src/pages/ManagerReservation/ManagerReservation.tsx rename frontend/src/pages/{ManagerReservationEdit/ManagerReservationEdit.styles.ts => ManagerReservation/units/ManagerReservationForm.styles.ts} (58%) create mode 100644 frontend/src/pages/ManagerReservation/units/ManagerReservationForm.tsx delete mode 100644 frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.tsx diff --git a/frontend/src/api/guestReservation.ts b/frontend/src/api/guestReservation.ts index a0676a2de..cc3c6febf 100644 --- a/frontend/src/api/guestReservation.ts +++ b/frontend/src/api/guestReservation.ts @@ -5,7 +5,7 @@ import { QueryGuestReservationsSuccess } from 'types/response'; import api from './api'; export interface QueryMapReservationsParams { - mapId: number | null; + mapId: number; date: string; } diff --git a/frontend/src/api/managerReservation.ts b/frontend/src/api/managerReservation.ts index ed8ae8332..e93c631ba 100644 --- a/frontend/src/api/managerReservation.ts +++ b/frontend/src/api/managerReservation.ts @@ -1,27 +1,42 @@ import { AxiosResponse } from 'axios'; import { QueryFunction, QueryKey } from 'react-query'; -import THROW_ERROR from 'constants/throwError'; -import { QueryManagerReservationsSuccess } from 'types/response'; +import { + QueryManagerMapReservationsSuccess, + QueryManagerSpaceReservationsSuccess, +} from 'types/response'; import api from './api'; export interface QueryMapReservationsParams { - mapId: number | null; + mapId: number; date: string; } -interface ReservationParams { +export interface QueryManagerSpaceReservationsParams extends QueryMapReservationsParams { + spaceId: number; +} + +export interface PostReservationParams { + mapId: number; + spaceId: number; reservation: { startDateTime: Date; endDateTime: Date; name: string; description: string; + password: string; }; } -interface PutReservationParams extends ReservationParams { +export interface PutReservationParams { mapId: number; spaceId: number; reservationId: number; + reservation: { + startDateTime: Date; + endDateTime: Date; + name: string; + description: string; + }; } interface DeleteReservationParams { @@ -30,20 +45,33 @@ interface DeleteReservationParams { reservationId: number; } -export const queryManagerReservations: QueryFunction< - AxiosResponse, +export const queryManagerSpaceReservations: QueryFunction< + AxiosResponse, + [QueryKey, QueryManagerSpaceReservationsParams] +> = ({ queryKey }) => { + const [, data] = queryKey; + const { mapId, spaceId, date } = data; + + return api.get(`/managers/maps/${mapId}/spaces/${spaceId}/reservations?date=${date}`); +}; + +export const queryManagerMapReservations: QueryFunction< + AxiosResponse, [QueryKey, QueryMapReservationsParams] > = ({ queryKey }) => { const [, data] = queryKey; const { mapId, date } = data; - if (!mapId) { - throw new Error(THROW_ERROR.INVALID_MAP_ID); - } - return api.get(`/managers/maps/${mapId}/spaces/reservations?date=${date}`); }; +export const postManagerReservation = ({ + reservation, + mapId, + spaceId, +}: PostReservationParams): Promise> => + api.post(`/managers/maps/${mapId}/spaces/${spaceId}/reservations`, reservation); + export const putManagerReservation = ({ reservation, mapId, diff --git a/frontend/src/api/managerSpaces.ts b/frontend/src/api/managerSpaces.ts index 72b1ad7ed..dca178a45 100644 --- a/frontend/src/api/managerSpaces.ts +++ b/frontend/src/api/managerSpaces.ts @@ -1,5 +1,6 @@ import { AxiosResponse } from 'axios'; import { QueryFunction, QueryKey } from 'react-query'; +import THROW_ERROR from 'constants/throwError'; import { QueryManagerSpacesSuccess } from 'types/response'; import api from './api'; @@ -14,5 +15,9 @@ export const queryManagerSpaces: QueryFunction< const [, data] = queryKey; const { mapId } = data; + if (!mapId) { + throw new Error(THROW_ERROR.INVALID_MAP_ID); + } + return api.get(`/managers/maps/${mapId}/spaces`); }; diff --git a/frontend/src/components/Button/Button.styles.ts b/frontend/src/components/Button/Button.styles.ts index 0d81e4963..9fa768105 100644 --- a/frontend/src/components/Button/Button.styles.ts +++ b/frontend/src/components/Button/Button.styles.ts @@ -3,7 +3,7 @@ import styled, { css } from 'styled-components'; interface Props { variant: 'primary' | 'primary-text' | 'text' | 'default'; shape: 'default' | 'round'; - size: 'small' | 'medium' | 'large'; + size: 'dense' | 'small' | 'medium' | 'large'; fullWidth: boolean; } @@ -47,6 +47,9 @@ const shapeCSS = { }; const sizeCSS = { + dense: css` + padding: 0 0.5rem; + `, small: css` padding: 0.25rem 0.5rem; `, diff --git a/frontend/src/components/Button/Button.tsx b/frontend/src/components/Button/Button.tsx index 974d03acd..235716037 100644 --- a/frontend/src/components/Button/Button.tsx +++ b/frontend/src/components/Button/Button.tsx @@ -4,7 +4,7 @@ import * as Styled from './Button.styles'; export interface Props extends ButtonHTMLAttributes { variant?: 'primary' | 'primary-text' | 'text' | 'default'; shape?: 'default' | 'round'; - size?: 'small' | 'medium' | 'large'; + size?: 'dense' | 'small' | 'medium' | 'large'; fullWidth?: boolean; } diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 04647976c..79dbda037 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -3,6 +3,7 @@ const PATH = { MANAGER_LOGIN: '/login', MANAGER_JOIN: '/join', MANAGER_MAIN: '/map', + MANAGER_RESERVATION: '/reservation', MANAGER_RESERVATION_EDIT: '/reservation/edit', MANAGER_MAP_CREATE: '/map/create', MANAGER_MAP_EDIT: '/map/:mapId/edit', diff --git a/frontend/src/constants/routes.tsx b/frontend/src/constants/routes.tsx index 4acf45745..6d7651c4a 100644 --- a/frontend/src/constants/routes.tsx +++ b/frontend/src/constants/routes.tsx @@ -8,9 +8,7 @@ const ManagerJoin = React.lazy(() => import('pages/ManagerJoin/ManagerJoin')); const ManagerLogin = React.lazy(() => import('pages/ManagerLogin/ManagerLogin')); const ManagerMain = React.lazy(() => import('pages/ManagerMain/ManagerMain')); const ManagerMapEditor = React.lazy(() => import('pages/ManagerMapEditor/ManagerMapEditor')); -const ManagerReservationEdit = React.lazy( - () => import('pages/ManagerReservationEdit/ManagerReservationEdit') -); +const ManagerReservation = React.lazy(() => import('pages/ManagerReservation/ManagerReservation')); const ManagerSpaceEditor = React.lazy(() => import('pages/ManagerSpaceEditor/ManagerSpaceEditor')); interface Route { @@ -55,9 +53,14 @@ export const PRIVATE_ROUTES: PrivateRoute[] = [ component: , redirectPath: PATH.MANAGER_LOGIN, }, + { + path: PATH.MANAGER_RESERVATION, + component: , + redirectPath: PATH.MANAGER_LOGIN, + }, { path: PATH.MANAGER_RESERVATION_EDIT, - component: , + component: , redirectPath: PATH.MANAGER_LOGIN, }, { diff --git a/frontend/src/hooks/query/useManagerMapReservations.ts b/frontend/src/hooks/query/useManagerMapReservations.ts new file mode 100644 index 000000000..f720e5873 --- /dev/null +++ b/frontend/src/hooks/query/useManagerMapReservations.ts @@ -0,0 +1,17 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { queryManagerMapReservations, QueryMapReservationsParams } from 'api/managerReservation'; +import { ErrorResponse, QueryManagerMapReservationsSuccess } from 'types/response'; + +const useManagerMapReservations = >( + { mapId, date }: QueryMapReservationsParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, QueryMapReservationsParams] + > +): UseQueryResult> => + useQuery(['getManagerMapReservations', { mapId, date }], queryManagerMapReservations, options); + +export default useManagerMapReservations; diff --git a/frontend/src/hooks/query/useManagerReservations.ts b/frontend/src/hooks/query/useManagerReservations.ts deleted file mode 100644 index 46fd757c4..000000000 --- a/frontend/src/hooks/query/useManagerReservations.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AxiosError, AxiosResponse } from 'axios'; -import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; -import { queryManagerReservations, QueryMapReservationsParams } from 'api/managerReservation'; -import { ErrorResponse, QueryManagerReservationsSuccess } from 'types/response'; - -const useManagerReservations = >( - { mapId, date }: QueryMapReservationsParams, - options?: UseQueryOptions< - AxiosResponse, - AxiosError, - TData, - [QueryKey, QueryMapReservationsParams] - > -): UseQueryResult> => - useQuery(['getManagerReservations', { mapId, date }], queryManagerReservations, options); - -export default useManagerReservations; diff --git a/frontend/src/hooks/query/useManagerSpaceReservations.ts b/frontend/src/hooks/query/useManagerSpaceReservations.ts new file mode 100644 index 000000000..1ee77cd59 --- /dev/null +++ b/frontend/src/hooks/query/useManagerSpaceReservations.ts @@ -0,0 +1,24 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { + queryManagerSpaceReservations, + QueryManagerSpaceReservationsParams, +} from 'api/managerReservation'; +import { ErrorResponse, QueryManagerSpaceReservationsSuccess } from 'types/response'; + +const useManagerSpaceReservations = >( + { mapId, spaceId, date }: QueryManagerSpaceReservationsParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, QueryManagerSpaceReservationsParams] + > +): UseQueryResult> => + useQuery( + ['getManagerSpaceReservations', { mapId, spaceId, date }], + queryManagerSpaceReservations, + options + ); + +export default useManagerSpaceReservations; diff --git a/frontend/src/pages/GuestReservation/GuestReservation.tsx b/frontend/src/pages/GuestReservation/GuestReservation.tsx index 5642c2116..ebc486d84 100644 --- a/frontend/src/pages/GuestReservation/GuestReservation.tsx +++ b/frontend/src/pages/GuestReservation/GuestReservation.tsx @@ -15,7 +15,7 @@ import { GuestMapState } from 'pages/GuestMap/GuestMap'; import { MapItem, Reservation, ScrollPosition, Space } from 'types/common'; import { ErrorResponse } from 'types/response'; import * as Styled from './GuestReservation.styles'; -import ReservationForm from './units/ReservationForm'; +import GuestReservationForm from './units/GuestReservationForm'; interface URLParameter { sharingMapId: MapItem['sharingMapId']; @@ -135,7 +135,7 @@ const GuestReservation = (): JSX.Element => { {space.name} - { const history = useHistory(); - const location = useLocation(); + const location = useLocation(); - const [date, setDate] = useState(new Date()); + const mapId = location.state?.mapId; + const targetDate = location.state?.targetDate; + + const [date, setDate] = useState(targetDate ?? new Date()); const [open, setOpen] = useState(false); - const [selectedMapId, setSelectedMapId] = useState(location.state?.mapId ?? null); + const [selectedMapId, setSelectedMapId] = useState(mapId ?? null); const [selectedMapName, setSelectedMapName] = useState(''); const [spacesOrder, setSpacesOrder] = useState(Order.Ascending); @@ -55,9 +61,9 @@ const ManagerMain = (): JSX.Element => { const organization = getMaps.data?.data.organization ?? ''; const maps = useMemo((): MapItemResponse[] => getMaps.data?.data.maps ?? [], [getMaps]); - const getReservations = useManagerReservations( + const getReservations = useManagerMapReservations( { - mapId: selectedMapId, + mapId: selectedMapId as number, date: formatDate(date), }, { @@ -66,6 +72,15 @@ const ManagerMain = (): JSX.Element => { } ); + const getSpaces = useManagerSpaces( + { + mapId: selectedMapId as number, + }, + { + enabled: !isNullish(selectedMapId), + } + ); + const removeReservation = useMutation(deleteManagerReservation, { onSuccess: () => { alert(MESSAGE.MANAGER_MAIN.RESERVATION_DELETE); @@ -82,6 +97,8 @@ const ManagerMain = (): JSX.Element => { [reservations, spacesOrder] ); + const spaces = useMemo(() => getSpaces.data?.data.spaces ?? [], [getSpaces]); + const handleClickSpacesOrder = () => { setSpacesOrder((prev) => (prev === Order.Ascending ? Order.Descending : Order.Ascending)); }; @@ -149,6 +166,19 @@ const ManagerMain = (): JSX.Element => { handleCloseDrawer(); }; + const handleCreateReservation = (spaceId: number) => { + if (!selectedMapId) return; + + history.push({ + pathname: PATH.MANAGER_RESERVATION, + state: { + mapId: selectedMapId, + space: spaces?.find(({ id }) => id === spaceId), + selectedDate: formatDate(date), + }, + }); + }; + const handleEditReservation = (reservation: Reservation, spaceId: number) => { if (!selectedMapId) return; @@ -156,7 +186,7 @@ const ManagerMain = (): JSX.Element => { pathname: PATH.MANAGER_RESERVATION_EDIT, state: { mapId: selectedMapId, - spaceId, + space: spaces?.find(({ id }) => id === spaceId), reservation, selectedDate: formatDate(date), }, @@ -258,9 +288,18 @@ const ManagerMain = (): JSX.Element => { {sortedReservations && sortedReservations.map(({ spaceId, spaceName, spaceColor, reservations }, index) => ( - + - {spaceName} + + {spaceName} + + {reservations.length === 0 ? ( diff --git a/frontend/src/pages/ManagerReservation/ManagerReservation.styles.ts b/frontend/src/pages/ManagerReservation/ManagerReservation.styles.ts new file mode 100644 index 000000000..95a31894b --- /dev/null +++ b/frontend/src/pages/ManagerReservation/ManagerReservation.styles.ts @@ -0,0 +1,31 @@ +import styled from 'styled-components'; +import ColorDotComponent from 'components/ColorDot/ColorDot'; + +interface Props { + isEditMode: boolean; +} + +export const Section = styled.section` + margin-top: ${({ isEditMode }) => (isEditMode ? '3rem' : '1.5rem')}; + margin-bottom: 4.5rem; +`; + +export const Message = styled.p` + white-space: pre-wrap; +`; + +export const ReservationList = styled.div` + border-top: 1px solid ${({ theme }) => theme.gray[400]}; +`; + +export const PageHeader = styled.h2` + font-size: 1.625rem; + font-weight: 700; + margin: 1.5rem 0; + display: flex; + align-items: center; +`; + +export const ColorDot = styled(ColorDotComponent)` + margin-right: 0.75rem; +`; diff --git a/frontend/src/pages/ManagerReservation/ManagerReservation.tsx b/frontend/src/pages/ManagerReservation/ManagerReservation.tsx new file mode 100644 index 000000000..b20b2238e --- /dev/null +++ b/frontend/src/pages/ManagerReservation/ManagerReservation.tsx @@ -0,0 +1,157 @@ +import { AxiosError } from 'axios'; +import { useEffect } from 'react'; +import { useMutation } from 'react-query'; +import { useHistory, useLocation } from 'react-router-dom'; +import { + postManagerReservation, + PostReservationParams, + putManagerReservation, + PutReservationParams, +} from 'api/managerReservation'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import PageHeader from 'components/PageHeader/PageHeader'; +import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; +import MESSAGE from 'constants/message'; +import PATH from 'constants/path'; +import useManagerSpaceReservations from 'hooks/query/useManagerSpaceReservations'; +import useInput from 'hooks/useInput'; +import { ManagerMainState } from 'pages/ManagerMain/ManagerMain'; +import { ManagerSpaceAPI, Reservation } from 'types/common'; +import { ErrorResponse } from 'types/response'; +import * as Styled from './ManagerReservation.styles'; +import ManagerReservationForm from './units/ManagerReservationForm'; + +export type EditReservationParams = Omit; +export type CreateReservationParams = Omit; + +interface ManagerReservationState { + mapId: number; + space: ManagerSpaceAPI; + selectedDate: string; + reservation?: Reservation; +} + +const ManagerReservation = (): JSX.Element => { + const location = useLocation(); + const history = useHistory(); + + const { mapId, space, selectedDate, reservation } = location.state; + + if (!mapId || !space) history.replace(PATH.MANAGER_MAIN); + + const [date, onChangeDate] = useInput(selectedDate); + + const isEditMode = !!reservation; + + const getReservations = useManagerSpaceReservations({ mapId, spaceId: space.id, date }); + const reservations = getReservations.data?.data?.reservations ?? []; + + const addReservation = useMutation(postManagerReservation, { + onSuccess: () => { + history.push({ + pathname: PATH.MANAGER_MAIN, + state: { + mapId, + targetDate: new Date(date), + }, + }); + }, + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); + }, + }); + + const updateReservation = useMutation(putManagerReservation, { + onSuccess: () => { + history.push({ + pathname: PATH.MANAGER_MAIN, + state: { + mapId, + targetDate: new Date(date), + }, + }); + }, + + onError: (error: AxiosError) => { + alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); + }, + }); + + const createReservation = ({ reservation }: CreateReservationParams) => { + if (addReservation.isLoading) return; + + addReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + }); + }; + + const editReservation = ({ reservation, reservationId }: EditReservationParams) => { + if (updateReservation.isLoading || !isEditMode || !reservationId) return; + + updateReservation.mutate({ + reservation, + mapId, + spaceId: space.id, + reservationId, + }); + }; + + useEffect(() => { + return history.listen((location) => { + if ( + location.pathname === PATH.MANAGER_MAIN || + location.pathname === PATH.MANAGER_MAIN + '/' + ) { + location.state = { + mapId, + targetDate: new Date(date), + }; + } + }); + }, [history, date, mapId]); + + return ( + <> +
+ + + + {space.name} + + + + + {getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.ERROR} + )} + {getReservations.isLoading && !getReservations.isLoadingError && ( + {MESSAGE.RESERVATION.PENDING} + )} + {getReservations.isSuccess && reservations.length === 0 && ( + {MESSAGE.RESERVATION.SUGGESTION} + )} + {getReservations.isSuccess && reservations.length > 0 && ( + + {reservations?.map((reservation) => ( + + ))} + + )} + + + + ); +}; + +export default ManagerReservation; diff --git a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.styles.ts b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.styles.ts similarity index 58% rename from frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.styles.ts rename to frontend/src/pages/ManagerReservation/units/ManagerReservationForm.styles.ts index 3fdf0ee9d..5723fe860 100644 --- a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.styles.ts +++ b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.styles.ts @@ -1,14 +1,7 @@ import styled from 'styled-components'; -import ColorDotComponent from 'components/ColorDot/ColorDot'; export const ReservationForm = styled.form` - margin: 1.5rem 0 5rem 0; -`; - -export const PageHeader = styled.h2` - font-size: 1.625rem; - font-weight: 700; - margin: 1.5rem 0; + margin: 1.5rem 0 0; `; export const Section = styled.section` @@ -26,10 +19,6 @@ export const InputWrapper = styled.div` } `; -export const ReservationList = styled.div` - border-top: 1px solid ${({ theme }) => theme.gray[400]}; -`; - export const ButtonWrapper = styled.div` position: fixed; bottom: 0; @@ -45,9 +34,3 @@ export const TimeFormMessage = styled.p` height: 1em; color: ${({ theme }) => theme.gray[500]}; `; - -export const Message = styled.p``; - -export const ColorDot = styled(ColorDotComponent)` - margin-right: 0.75rem; -`; diff --git a/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.tsx b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.tsx new file mode 100644 index 000000000..91f6361dc --- /dev/null +++ b/frontend/src/pages/ManagerReservation/units/ManagerReservationForm.tsx @@ -0,0 +1,205 @@ +import { ChangeEventHandler } from 'react'; +import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; +import Button from 'components/Button/Button'; +import Input from 'components/Input/Input'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import RESERVATION from 'constants/reservation'; +import TIME from 'constants/time'; +import useInputs from 'hooks/useInputs'; +import useScrollToTop from 'hooks/useScrollToTop'; +import { ManagerSpaceAPI, Reservation } from 'types/common'; +import { formatDate, formatTime, formatTimePrettier } from 'utils/datetime'; +import { CreateReservationParams, EditReservationParams } from '../ManagerReservation'; +import * as Styled from './ManagerReservationForm.styles'; + +interface Props { + isEditMode: boolean; + space: ManagerSpaceAPI; + reservation?: Reservation; + date: string; + onChangeDate: ChangeEventHandler; + onCreateReservation: ({ reservation }: CreateReservationParams) => void; + onEditReservation: ({ reservation, reservationId }: EditReservationParams) => void; +} + +interface Form { + name: string; + description: string; + startTime: string; + endTime: string; + password: string; +} + +const ManagerReservationForm = ({ + isEditMode, + space, + date, + reservation, + onChangeDate, + onCreateReservation, + onEditReservation, +}: Props): JSX.Element => { + useScrollToTop(); + + const { availableStartTime, availableEndTime, reservationTimeUnit, reservationMaximumTimeUnit } = + space.settings; + + const now = new Date(); + const todayDate = formatDate(new Date()); + + const getInitialStartTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.startDateTime)); + } + + return formatTime(now); + }; + + const getInitialEndTime = () => { + if (isEditMode && reservation) { + return formatTime(new Date(reservation.endDateTime)); + } + + return formatTime( + new Date(new Date().getTime() + TIME.MILLISECONDS_PER_MINUTE * reservationTimeUnit) + ); + }; + + const initialStartTime = getInitialStartTime(); + const initialEndTime = getInitialEndTime(); + + const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); + const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); + + const [{ name, description, startTime, endTime, password }, onChangeForm] = useInputs
({ + name: reservation?.name ?? '', + description: reservation?.description ?? '', + startTime: initialStartTime, + endTime: initialEndTime, + password: '', + }); + + const startDateTime = new Date(`${date}T${startTime}Z`); + const endDateTime = new Date(`${date}T${endTime}Z`); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!reservation) { + onCreateReservation({ + reservation: { + startDateTime, + endDateTime, + password, + name, + description, + }, + }); + + return; + } + + onEditReservation({ + reservation: { + startDateTime, + endDateTime, + name, + description, + }, + reservationId: reservation?.id, + }); + }; + + return ( + handleSubmit(event)}> + + + + + + + + + } + value={date} + min={formatDate(now)} + onChange={onChangeDate} + required + /> + + + + + + 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} + {formatTimePrettier(reservationMaximumTimeUnit)}) + + + {isEditMode || ( + + + + )} + + + + + + ); +}; + +export default ManagerReservationForm; diff --git a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.tsx b/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.tsx deleted file mode 100644 index fbb53cf95..000000000 --- a/frontend/src/pages/ManagerReservationEdit/ManagerReservationEdit.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { AxiosError } from 'axios'; -import { FormEventHandler } from 'react'; -import { useMutation } from 'react-query'; -import { useHistory, useLocation } from 'react-router-dom'; -import { putManagerReservation } from 'api/managerReservation'; -import { ReactComponent as CalendarIcon } from 'assets/svg/calendar.svg'; -import Button from 'components/Button/Button'; -import Header from 'components/Header/Header'; -import Input from 'components/Input/Input'; -import Layout from 'components/Layout/Layout'; -import PageHeader from 'components/PageHeader/PageHeader'; -import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; -import MESSAGE from 'constants/message'; -import PATH from 'constants/path'; -import RESERVATION from 'constants/reservation'; -import useGuestReservations from 'hooks/query/useGuestReservations'; -import useManagerSpace from 'hooks/query/useManagerSpace'; -import useInput from 'hooks/useInput'; -import useListenManagerMainState from 'hooks/useListenManagerMainState'; -import { GuestMapState } from 'pages/GuestMap/GuestMap'; -import { Reservation } from 'types/common'; -import { ErrorResponse } from 'types/response'; -import { formatDate, formatTime } from 'utils/datetime'; -import * as Styled from './ManagerReservationEdit.styles'; - -interface ManagerReservationEditState { - mapId: number; - spaceId: number; - reservation: Reservation; - selectedDate: string; -} - -const ManagerReservationEdit = (): JSX.Element => { - const location = useLocation(); - const history = useHistory(); - - const { mapId, spaceId, reservation, selectedDate } = location.state; - - if (!mapId || !spaceId || !reservation) history.replace(PATH.MANAGER_MAIN); - - useListenManagerMainState({ mapId: Number(mapId) }); - - const getSpace = useManagerSpace({ mapId, spaceId }); - const space = getSpace.data?.data.data; - - const availableStartTime = space?.settings?.availableStartTime ?? ''; - const availableEndTime = space?.settings?.availableEndTime ?? ''; - const reservationTimeUnit = space?.settings?.reservationTimeUnit ?? 0; - const reservationMaximumTimeUnit = space?.settings?.reservationMaximumTimeUnit ?? 0; - - const now = new Date(); - const todayDate = formatDate(new Date()); - - const [name, onChangeName] = useInput(reservation.name); - const [description, onChangeDescription] = useInput(reservation.description); - const [date, onChangeDate] = useInput(selectedDate); - const [startTime, onChangeStartTime] = useInput(formatTime(new Date(reservation.startDateTime))); - const [endTime, onChangeEndTime] = useInput(formatTime(new Date(reservation.endDateTime))); - - const startDateTime = new Date(`${date}T${startTime}Z`); - const endDateTime = new Date(`${date}T${endTime}Z`); - - const availableStartTimeText = formatTime(new Date(`${todayDate}T${availableStartTime}`)); - const availableEndTimeText = formatTime(new Date(`${todayDate}T${availableEndTime}`)); - - const getReservations = useGuestReservations({ mapId, spaceId, date }); - const reservations = getReservations.data?.data?.reservations ?? []; - - const editReservation = useMutation(putManagerReservation, { - onSuccess: () => { - history.push(PATH.MANAGER_MAIN, { - spaceId, - targetDate: new Date(`${date}T${startTime}`), - }); - }, - - onError: (error: AxiosError) => { - alert(error.response?.data.message ?? MESSAGE.RESERVATION.UNEXPECTED_ERROR); - }, - }); - - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - - if (editReservation.isLoading) return; - - const editReservationParams = { - name, - description, - startDateTime, - endDateTime, - }; - - editReservation.mutate({ - reservation: editReservationParams, - mapId, - spaceId, - reservationId: reservation.id, - }); - }; - - return ( - <> -
- - - - {space && ( - - - {space.name} - - )} - - - - - - - - } - value={date} - min={formatDate(now)} - onChange={onChangeDate} - required - /> - - - - - - {/* TODO 현재 NaN으로 표시 */} - {/* - 예약 가능 시간 : {availableStartTimeText} ~ {availableEndTimeText} (최대{' '} - {formatTimePrettier(reservationMaximumTimeUnit)}) - */} - - - - - {getReservations.isLoadingError && ( - - 예약 목록을 불러오는 데 문제가 생겼어요! -
- 새로 고침으로 다시 시도해주세요. -
- )} - {getReservations.isLoading && !getReservations.isLoadingError && ( - 불러오는 중입니다... - )} - {getReservations.isSuccess && reservations.length > 0 && ( - - {reservations?.map((reservation) => ( - - ))} - - )} -
- - - -
-
- - ); -}; - -export default ManagerReservationEdit; diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 32381cae0..396cd0ede 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -26,7 +26,10 @@ export interface QueryManagerMapsSuccess { organization: string; } -export interface QueryManagerReservationsSuccess { +export interface QueryManagerSpaceReservationsSuccess { + reservations: Reservation[]; +} +export interface QueryManagerMapReservationsSuccess { data: SpaceReservation[]; } From 8b62438a61e4b51045b4c769e1f771019e24feeb Mon Sep 17 00:00:00 2001 From: xrabcde Date: Fri, 17 Sep 2021 03:06:16 +0900 Subject: [PATCH 17/25] =?UTF-8?q?feat:=20DB=20Replication=EC=9D=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4.=20(#558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: submoudle slave datasource update * feat: DataSource Routing 기능 추가 * chore: submodule update 반영 * feat: hibernate 설정 주입 * chore: db replication & time zone 관련 submodule 업데이트 반영 * refactor: master slave 설정 분기 나누도록 리팩터링 * chore: ddl auto submodule update 반영 * fix: 필요한 readOnly 추가 및 불필요한 어노테이션 제거 * refactor: 매직 상수 정리 * chore: jacoco 에서 datasource 관련 클래스들 exclude하도록 설정 * chore: sonarqube 에서 datasource 관련 클래스들은 제외 * chore: submodule ddl auto 설정 추가 Co-authored-by: sakjung --- backend/build.gradle | 4 +- .../config/datasource/CircularList.java | 19 ++++ .../datasource/CustomDataSourceConfig.java | 93 +++++++++++++++++++ .../CustomDataSourceProperties.java | 36 +++++++ .../ReplicationRoutingDataSource.java | 39 ++++++++ .../NoMasterDataSourceException.java | 12 +++ .../zzimkkong/service/MapService.java | 15 +-- backend/src/main/resources/config | 2 +- 8 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/NoMasterDataSourceException.java diff --git a/backend/build.gradle b/backend/build.gradle index 0c4869ff5..7eba3dee0 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -111,7 +111,7 @@ jacocoTestCoverageVerification { excludes = ["**.exception.**", "**.ControllerAdvice", "**.*ErrorResponse", "**.ValidatorMessage", "**.ZzimkkongApplication", "**.*TimeConverter", "**.DataLoader", "**.*Config", "**.LoginInterceptor", "**.AuthenticationPrincipalArgumentResolver", - "**.slack.**", "**.Slack*"] + "**.slack.**", "**.Slack*", "**.datasource.**"] //todo: 패키지 분리하면 더 멋있게 해보겠슴,, limit { @@ -145,6 +145,6 @@ sonarqube { property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml' property 'sonar.java.binaries', 'build/classes' property 'sonar.test.inclusions', '**/*Test.java' - property 'sonar.exclusions', '**/*Doc*.java, **/resources/**' + property 'sonar.exclusions', '**/*Doc*.java, **/resources/**, **/config/datasource/**' } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java new file mode 100644 index 000000000..796dc6497 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java @@ -0,0 +1,19 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import java.util.List; + +public class CircularList { + private final List list; + private Integer counter = 0; + + public CircularList(List list) { + this.list = list; + } + + public T getOne() { + if (counter + 1 >= list.size()) { + counter = -1; + } + return list.get(++counter); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java new file mode 100644 index 000000000..1a8edc61f --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java @@ -0,0 +1,93 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import com.woowacourse.zzimkkong.exception.infrastructure.NoMasterDataSourceException; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import java.util.*; +import java.util.stream.Collectors; + +@Configuration +@Profile("prod") +public class CustomDataSourceConfig { + public static final String MASTER = "master"; + public static final String SLAVE = "slave"; + private static final String PACKAGE_PATH = "com.woowacourse.zzimkkong"; + private final List hikariDataSources; + private final JpaProperties jpaProperties; + + public CustomDataSourceConfig(final List hikariDataSources, final JpaProperties jpaProperties) { + this.hikariDataSources = hikariDataSources; + this.jpaProperties = jpaProperties; + } + + @Bean + public DataSource dataSource() { + return new LazyConnectionDataSourceProxy(routingDataSource()); + } + + @Bean + public DataSource routingDataSource() { + final DataSource master = createMasterDataSource(); + final Map slaves = createSlaveDataSources(); + slaves.put(MASTER, master); + + ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource(); + replicationRoutingDataSource.setDefaultTargetDataSource(master); + replicationRoutingDataSource.setTargetDataSources(slaves); + return replicationRoutingDataSource; + } + + private DataSource createMasterDataSource() { + return hikariDataSources.stream() + .filter(dataSource -> dataSource.getPoolName().startsWith(MASTER)) + .findFirst() + .orElseThrow(NoMasterDataSourceException::new); + } + + private Map createSlaveDataSources() { + final List slaveDataSources = hikariDataSources.stream() + .filter(datasource -> Objects.nonNull(datasource.getPoolName()) && datasource.getPoolName().startsWith(SLAVE)) + .collect(Collectors.toList()); + + final Map result = new HashMap<>(); + for (final HikariDataSource slaveDataSource : slaveDataSources) { + result.put(slaveDataSource.getPoolName(), slaveDataSource); + } + return result; + } + + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactory() { + EntityManagerFactoryBuilder entityManagerFactoryBuilder = createEntityManagerFactoryBuilder(jpaProperties); + return entityManagerFactoryBuilder.dataSource(dataSource()).packages(PACKAGE_PATH).build(); + } + + private EntityManagerFactoryBuilder createEntityManagerFactoryBuilder(JpaProperties jpaProperties) { + AbstractJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + return new EntityManagerFactoryBuilder(vendorAdapter, jpaProperties.getProperties(), null); + } + + @Bean + public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { + JpaTransactionManager tm = new JpaTransactionManager(); + tm.setEntityManagerFactory(entityManagerFactory); + return tm; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java new file mode 100644 index 000000000..ffb4a9b2d --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java @@ -0,0 +1,36 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("prod") +public class CustomDataSourceProperties { + @Bean + @ConfigurationProperties("app.datasource.master") + public DataSourceProperties masterDataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + @ConfigurationProperties("app.datasource.master.hikari") + public HikariDataSource masterDataSource() { + return masterDataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Bean + @ConfigurationProperties("app.datasource.slave1") + public DataSourceProperties slave1DataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + @ConfigurationProperties("app.datasource.slave1.hikari") + public HikariDataSource slave1DataSource() { + return slave1DataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java new file mode 100644 index 000000000..f7a4616a6 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java @@ -0,0 +1,39 @@ +package com.woowacourse.zzimkkong.config.datasource; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Map; +import java.util.stream.Collectors; + +import static com.woowacourse.zzimkkong.config.datasource.CustomDataSourceConfig.MASTER; +import static com.woowacourse.zzimkkong.config.datasource.CustomDataSourceConfig.SLAVE; + +public class ReplicationRoutingDataSource extends AbstractRoutingDataSource { + private CircularList dataSourceNameList; + + @Override + public void setTargetDataSources(Map targetDataSources) { + super.setTargetDataSources(targetDataSources); + + dataSourceNameList = new CircularList<>( + targetDataSources.keySet() + .stream() + .map(Object::toString) + .filter(string -> string.contains(SLAVE)) + .collect(Collectors.toList()) + ); + } + + @Override + protected Object determineCurrentLookupKey() { + boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); + if (isReadOnly) { + logger.info("Connection Slave"); + return dataSourceNameList.getOne(); + } else { + logger.info("Connection Master"); + return MASTER; + } + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/NoMasterDataSourceException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/NoMasterDataSourceException.java new file mode 100644 index 000000000..81f47ac2d --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/NoMasterDataSourceException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class NoMasterDataSourceException extends ZzimkkongException { + private static final String MESSAGE = "Master DB의 DataSource 설정이 올바르지 않습니다."; + + public NoMasterDataSourceException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java index fb7b650f4..4fe4b0f6b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/MapService.java @@ -71,6 +71,14 @@ public MapFindAllResponse findAllMaps(final Member manager) { .collect(collectingAndThen(toList(), mapFindResponses -> MapFindAllResponse.of(mapFindResponses, manager))); } + @Transactional(readOnly = true) + public MapFindResponse findMapBySharingId(final String sharingMapId) { + Long mapId = sharingIdGenerator.parseIdFrom(sharingMapId); + Map map = maps.findById(mapId) + .orElseThrow(NoSuchMapException::new); + return MapFindResponse.of(map, sharingIdGenerator.from(map)); + } + public void updateMap(final Long mapId, final MapCreateUpdateRequest mapCreateUpdateRequest, final Member manager) { Map map = maps.findById(mapId) @@ -112,11 +120,4 @@ public static void validateManagerOfMap(final Map map, final Member manager) { throw new NoAuthorityOnMapException(); } } - - public MapFindResponse findMapBySharingId(final String sharingMapId) { - Long mapId = sharingIdGenerator.parseIdFrom(sharingMapId); - Map map = maps.findById(mapId) - .orElseThrow(NoSuchMapException::new); - return MapFindResponse.of(map, sharingIdGenerator.from(map)); - } } diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index ab1367ded..c0fefc075 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit ab1367dedea78d65f57a41b56f46c2a131a3de5b +Subproject commit c0fefc075ccdc956cce010686cf79f421ee9cd5c From aece9a367921b0248250f096f2693bd706e2c70a Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Fri, 17 Sep 2021 09:57:29 +0900 Subject: [PATCH 18/25] =?UTF-8?q?feat:=20Github,=20Google=20OAuth=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=84=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#560)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: .env 파일 및 설정 추가 * feat: `useQueryString` 커스텀 훅 구현 * feat: `SocialJoinButton` 컴포넌트 구현 - `SocialJoinButton`: 소셜 회원가입 버튼 컴포넌트 - `SocialLoginButton` 컴포넌트와 스타일을 공유하기 위해 `SocialAuthButton.styles.ts`에서 스타일을 가져옴 * refactor: `Input` 컴포넌트에 disabled 스타일 적용 * feat: Github, Google 로그인 커스텀 훅 및 회원가입 API 구현 * refactor: 로그인 페이지에서 변경된 `SocialAuthButton` 적용 * feat: Github, Google OAuth 로그인 구현 - `GithubOAuthRedirect`, `GoogleOAuthRedirect` : OAuth 인증 후 Redirect되는 URI에 해당하는 페이지 컴포넌트 - `ManagerSocialJoin`: 처음 로그인하는 계정일 경우, 조직명을 입력받아 회원가입하는 페이지 컴포넌트 * refactor: `PrivateRoute`에서 `token`을 Recoil이 아닌 `localStorage`에서 가져오도록 수정 - 소셜 로그인 이후, 메인 페이지로 Redirect되어야 하는 과정에서 Recoil의 token값이 미처 업데이트되지 않아 계속 로그인으로 돌아가는 문제가 있음 --- frontend/.gitignore | 1 + frontend/package.json | 1 + frontend/src/PrivateRoute.tsx | 9 ++- frontend/src/api/join.ts | 21 ++++++ frontend/src/api/login.ts | 24 +++++++ frontend/src/components/Input/Input.styles.ts | 4 ++ .../SociaLoginButton.stories.tsx} | 24 +++++-- .../SocialAuthButton.styles.ts} | 23 ++++-- .../SocialJoinButton.stories.tsx | 26 +++++++ .../SocialAuthButton/SocialJoinButton.tsx | 30 ++++++++ .../SocialLoginButton.tsx | 8 +-- frontend/src/constants/path.ts | 31 ++++++++ frontend/src/constants/routes.tsx | 15 ++++ frontend/src/hooks/query/useGithubLogin.ts | 21 ++++++ frontend/src/hooks/query/useGoogleLogin.ts | 21 ++++++ frontend/src/hooks/useQueryString.ts | 5 ++ .../src/pages/ManagerLogin/ManagerLogin.tsx | 6 +- .../ManagerSocialJoin.styles.ts | 14 ++++ .../ManagerSocialJoin/ManagerSocialJoin.tsx | 63 +++++++++++++++++ .../units/SocialJoinForm.styles.ts | 9 +++ .../units/SocialJoinForm.tsx | 70 +++++++++++++++++++ .../OAuthRedirect/GithubOAuthRedirect.tsx | 55 +++++++++++++++ .../OAuthRedirect/GoogleOAuthRedirect.tsx | 55 +++++++++++++++ frontend/src/types/response.ts | 10 +++ frontend/webpack.config.js | 2 + frontend/yarn.lock | 16 ++++- 26 files changed, 541 insertions(+), 23 deletions(-) rename frontend/src/components/{SocialLoginButton/SocialLoginButton.stories.tsx => SocialAuthButton/SociaLoginButton.stories.tsx} (51%) rename frontend/src/components/{SocialLoginButton/SocialLoginButton.styles.ts => SocialAuthButton/SocialAuthButton.styles.ts} (56%) create mode 100644 frontend/src/components/SocialAuthButton/SocialJoinButton.stories.tsx create mode 100644 frontend/src/components/SocialAuthButton/SocialJoinButton.tsx rename frontend/src/components/{SocialLoginButton => SocialAuthButton}/SocialLoginButton.tsx (87%) create mode 100644 frontend/src/hooks/query/useGithubLogin.ts create mode 100644 frontend/src/hooks/query/useGoogleLogin.ts create mode 100644 frontend/src/hooks/useQueryString.ts create mode 100644 frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.styles.ts create mode 100644 frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx create mode 100644 frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts create mode 100644 frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx create mode 100644 frontend/src/pages/OAuthRedirect/GithubOAuthRedirect.tsx create mode 100644 frontend/src/pages/OAuthRedirect/GoogleOAuthRedirect.tsx diff --git a/frontend/.gitignore b/frontend/.gitignore index 5f9aaf8e1..bb43c2839 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,4 +2,5 @@ node_modules dist .eslintcache +.env *.log diff --git a/frontend/package.json b/frontend/package.json index e8af4d1dd..6ce765005 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,6 +67,7 @@ "babel-eslint": "^10.0.0", "babel-loader": "^8.2.2", "cross-env": "^7.0.3", + "dotenv-webpack": "^7.0.3", "eslint": "^7.5.0", "eslint-config-prettier": "^8.3.0", "eslint-config-react-app": "^6.0.0", diff --git a/frontend/src/PrivateRoute.tsx b/frontend/src/PrivateRoute.tsx index ffb69f901..0fec8bf16 100644 --- a/frontend/src/PrivateRoute.tsx +++ b/frontend/src/PrivateRoute.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren } from 'react'; import { Redirect, Route, RouteProps } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; -import accessTokenState from 'state/accessTokenState'; +import { LOCAL_STORAGE_KEY } from 'constants/storage'; +import { getLocalStorageItem } from 'utils/localStorage'; interface Props extends RouteProps { redirectPath: string; @@ -12,7 +12,10 @@ const PrivateRoute = ({ children, ...props }: PropsWithChildren): JSX.Element => { - const token = useRecoilValue(accessTokenState); + const token = getLocalStorageItem({ + key: LOCAL_STORAGE_KEY.ACCESS_TOKEN, + defaultValue: '', + }); return {token ? children : }; }; diff --git a/frontend/src/api/join.ts b/frontend/src/api/join.ts index 32647e11c..9bd791221 100644 --- a/frontend/src/api/join.ts +++ b/frontend/src/api/join.ts @@ -9,6 +9,16 @@ interface JoinParams { organization: string; } +interface SocialJoinParams { + email: string; + organization: string; + oauthProvider: 'GITHUB' | 'GOOGLE'; +} + +export interface QueryEmailParams { + code: string; +} + export const queryValidateEmail: QueryFunction = ({ queryKey }) => { const [, email] = queryKey; @@ -20,3 +30,14 @@ export const queryValidateEmail: QueryFunction = ({ queryKey }) => { export const postJoin = ({ email, password, organization }: JoinParams): Promise => { return api.post('/managers', { email, password, organization }); }; + +export const postSocialJoin = ({ + email, + organization, + oauthProvider, +}: SocialJoinParams): Promise => + api.post(`/api/managers/oauth`, { + email, + organization, + oauthProvider, + }); diff --git a/frontend/src/api/login.ts b/frontend/src/api/login.ts index 5fe631ab0..2a2e450f1 100644 --- a/frontend/src/api/login.ts +++ b/frontend/src/api/login.ts @@ -1,4 +1,6 @@ import { AxiosResponse } from 'axios'; +import { QueryFunction, QueryKey } from 'react-query'; +import { LoginSuccess } from 'types/response'; import api from './api'; interface LoginParams { @@ -6,6 +8,28 @@ interface LoginParams { password: string; } +export interface SocialLoginParams { + code: string; +} + export const postLogin = (loginData: LoginParams): Promise => { return api.post('/managers/login/token', loginData); }; + +export const queryGithubLogin: QueryFunction< + AxiosResponse, + [QueryKey, SocialLoginParams] +> = ({ queryKey }) => { + const [, { code }] = queryKey; + + return api.get(`/managers/github/login/token?code=${code}`); +}; + +export const queryGoogleLogin: QueryFunction< + AxiosResponse, + [QueryKey, SocialLoginParams] +> = ({ queryKey }) => { + const [, { code }] = queryKey; + + return api.get(`/api/managers/google/login/token?code=${code}`); +}; diff --git a/frontend/src/components/Input/Input.styles.ts b/frontend/src/components/Input/Input.styles.ts index 8945d97c0..0aad51464 100644 --- a/frontend/src/components/Input/Input.styles.ts +++ b/frontend/src/components/Input/Input.styles.ts @@ -68,6 +68,10 @@ export const Input = styled.input` border-color: ${({ theme }) => theme.primary[400]}; box-shadow: inset 0px 0px 0px 1px ${({ theme }) => theme.primary[400]}; } + + &:disabled { + color: ${({ theme }) => theme.gray[400]}; + } `; export const Message = styled.p` diff --git a/frontend/src/components/SocialLoginButton/SocialLoginButton.stories.tsx b/frontend/src/components/SocialAuthButton/SociaLoginButton.stories.tsx similarity index 51% rename from frontend/src/components/SocialLoginButton/SocialLoginButton.stories.tsx rename to frontend/src/components/SocialAuthButton/SociaLoginButton.stories.tsx index 566034d3f..bbcc1dc69 100644 --- a/frontend/src/components/SocialLoginButton/SocialLoginButton.stories.tsx +++ b/frontend/src/components/SocialAuthButton/SociaLoginButton.stories.tsx @@ -7,7 +7,7 @@ export default { component: SocialLoginButton, argTypes: { provider: { - options: ['github', 'google'], + options: ['GITHUB', 'GOOGLE'], control: { type: 'radio' }, }, }, @@ -15,12 +15,22 @@ export default { const Template: Story> = (args) => ; -export const Github = Template.bind({}); -Github.args = { - provider: 'github', +export const GithubLogin = Template.bind({}); +GithubLogin.args = { + provider: 'GITHUB', }; -export const Google = Template.bind({}); -Google.args = { - provider: 'google', +export const GoogleLogin = Template.bind({}); +GoogleLogin.args = { + provider: 'GOOGLE', +}; + +export const GithubJoin = Template.bind({}); +GithubJoin.args = { + provider: 'GITHUB', +}; + +export const GoogleJoin = Template.bind({}); +GoogleJoin.args = { + provider: 'GOOGLE', }; diff --git a/frontend/src/components/SocialLoginButton/SocialLoginButton.styles.ts b/frontend/src/components/SocialAuthButton/SocialAuthButton.styles.ts similarity index 56% rename from frontend/src/components/SocialLoginButton/SocialLoginButton.styles.ts rename to frontend/src/components/SocialAuthButton/SocialAuthButton.styles.ts index 4c29cd096..944ed828d 100644 --- a/frontend/src/components/SocialLoginButton/SocialLoginButton.styles.ts +++ b/frontend/src/components/SocialAuthButton/SocialAuthButton.styles.ts @@ -1,22 +1,22 @@ import styled, { css } from 'styled-components'; import PALETTE from 'constants/palette'; -import { Props } from './SocialLoginButton'; +import { Props as JoinButtonProps } from './SocialJoinButton'; +import { Props as LoginButtonProps } from './SocialLoginButton'; const providerCSS = { - github: css` + GITHUB: css` background-color: ${PALETTE.GITHUB}; color: ${PALETTE.WHITE}; border: none; `, - google: css` + GOOGLE: css` background-color: ${PALETTE.WHITE}; color: ${PALETTE.BLACK[700]}; border: 1px solid ${PALETTE.BLACK[700]}; `, }; -export const SocialLoginButton = styled.a` - ${({ provider }) => providerCSS[provider]}; +const buttonCSS = css` display: flex; justify-content: center; align-items: center; @@ -24,6 +24,19 @@ export const SocialLoginButton = styled.a` text-decoration: none; padding: 0.75rem 1rem; font-size: 1.25rem; + border-radius: 0.125rem; +`; + +export const SocialLoginButton = styled.a` + ${({ provider }) => providerCSS[provider]} + ${buttonCSS}; +`; + +export const SocialJoinButton = styled.button` + ${({ provider }) => providerCSS[provider]} + ${buttonCSS}; + width: 100%; + cursor: pointer; `; export const Icon = styled.div` diff --git a/frontend/src/components/SocialAuthButton/SocialJoinButton.stories.tsx b/frontend/src/components/SocialAuthButton/SocialJoinButton.stories.tsx new file mode 100644 index 000000000..4e5045153 --- /dev/null +++ b/frontend/src/components/SocialAuthButton/SocialJoinButton.stories.tsx @@ -0,0 +1,26 @@ +import { Story } from '@storybook/react'; +import { PropsWithChildren } from 'react'; +import SocialJoinButton, { Props } from './SocialJoinButton'; + +export default { + title: 'shared/SocialJoinButton', + component: SocialJoinButton, + argTypes: { + provider: { + options: ['GITHUB', 'GOOGLE'], + control: { type: 'radio' }, + }, + }, +}; + +const Template: Story> = (args) => ; + +export const GithubJoin = Template.bind({}); +GithubJoin.args = { + provider: 'GITHUB', +}; + +export const GoogleJoin = Template.bind({}); +GoogleJoin.args = { + provider: 'GOOGLE', +}; diff --git a/frontend/src/components/SocialAuthButton/SocialJoinButton.tsx b/frontend/src/components/SocialAuthButton/SocialJoinButton.tsx new file mode 100644 index 000000000..a10ae42e4 --- /dev/null +++ b/frontend/src/components/SocialAuthButton/SocialJoinButton.tsx @@ -0,0 +1,30 @@ +import { ButtonHTMLAttributes } from 'react'; +import { ReactComponent as GithubIcon } from 'assets/svg/github-logo.svg'; +import { ReactComponent as GoogleIcon } from 'assets/svg/google-logo.svg'; +import * as Styled from './SocialAuthButton.styles'; + +export interface Props extends ButtonHTMLAttributes { + provider: 'GITHUB' | 'GOOGLE'; +} + +const social = { + GITHUB: { + icon: , + text: 'Github로 시작하기', + }, + GOOGLE: { + icon: , + text: 'Google로 시작하기', + }, +}; + +const SocialJoinButton = ({ provider, ...props }: Props): JSX.Element => { + return ( + + {social[provider].icon} + {social[provider].text} + + ); +}; + +export default SocialJoinButton; diff --git a/frontend/src/components/SocialLoginButton/SocialLoginButton.tsx b/frontend/src/components/SocialAuthButton/SocialLoginButton.tsx similarity index 87% rename from frontend/src/components/SocialLoginButton/SocialLoginButton.tsx rename to frontend/src/components/SocialAuthButton/SocialLoginButton.tsx index 86ebe923d..0cb305f2c 100644 --- a/frontend/src/components/SocialLoginButton/SocialLoginButton.tsx +++ b/frontend/src/components/SocialAuthButton/SocialLoginButton.tsx @@ -1,18 +1,18 @@ import { AnchorHTMLAttributes } from 'react'; import { ReactComponent as GithubIcon } from 'assets/svg/github-logo.svg'; import { ReactComponent as GoogleIcon } from 'assets/svg/google-logo.svg'; -import * as Styled from './SocialLoginButton.styles'; +import * as Styled from './SocialAuthButton.styles'; export interface Props extends AnchorHTMLAttributes { - provider: 'github' | 'google'; + provider: 'GITHUB' | 'GOOGLE'; } const social = { - github: { + GITHUB: { icon: , text: 'Github로 로그인', }, - google: { + GOOGLE: { icon: , text: 'Google로 로그인', }, diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 79dbda037..8c3b08661 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -1,7 +1,28 @@ +const GITHUB_OAUTH_KEY = (() => { + if (process.env.NODE_ENV === 'development' && process.env.DEPLOY_ENV === 'development') { + return process.env.GITHUB_OAUTH_KEY_LOCAL ?? ''; + } + + if (process.env.NODE_ENV === 'production' && process.env.DEPLOY_ENV === 'development') { + return process.env.GITHUB_OAUTH_KEY_DEV ?? ''; + } + + if (process.env.NODE_ENV === 'production' && process.env.DEPLOY_ENV === 'production') { + return process.env.GITHUB_OAUTH_KEY_PROD ?? ''; + } + + return ''; +})(); + +const GOOGLE_OAUTH_KEY = process.env.GOOGLE_OAUTH_KEY ?? ''; + const PATH = { MAIN: '/', MANAGER_LOGIN: '/login', MANAGER_JOIN: '/join', + MANAGER_SOCIAL_JOIN: '/join/social', + MANAGER_GITHUB_OAUTH_REDIRECT: '/login/oauth/github', + MANAGER_GOOGLE_OAUTH_REDIRECT: '/login/oauth/google', MANAGER_MAIN: '/map', MANAGER_RESERVATION: '/reservation', MANAGER_RESERVATION_EDIT: '/reservation/edit', @@ -12,6 +33,16 @@ const PATH = { GUEST_RESERVATION: '/guest/:sharingMapId/reservation', GUEST_RESERVATION_EDIT: '/guest/:sharingMapId/reservation/edit', NOT_FOUND: '/not-found', + GITHUB_LOGIN: `https://github.com/login/oauth/authorize?client_id=${GITHUB_OAUTH_KEY}&redirect_uri=http://localhost:3000/login/oauth/github`, + GOOGLE_LOGIN: + 'https://accounts.google.com/o/oauth2/v2/auth?' + + 'scope=https://www.googleapis.com/auth/userinfo.email&' + + 'access_type=offline&' + + 'include_granted_scopes=true&' + + 'response_type=code&' + + 'state=state_parameter_passthrough_value&' + + 'redirect_uri=http://localhost:3000/login/oauth/google&' + + `client_id=${GOOGLE_OAUTH_KEY}`, }; export const HREF = { diff --git a/frontend/src/constants/routes.tsx b/frontend/src/constants/routes.tsx index 6d7651c4a..acfddfc63 100644 --- a/frontend/src/constants/routes.tsx +++ b/frontend/src/constants/routes.tsx @@ -5,11 +5,14 @@ const GuestMap = React.lazy(() => import('pages/GuestMap/GuestMap')); const GuestReservation = React.lazy(() => import('pages/GuestReservation/GuestReservation')); const Main = React.lazy(() => import('pages/Main/Main')); const ManagerJoin = React.lazy(() => import('pages/ManagerJoin/ManagerJoin')); +const ManagerSocialJoin = React.lazy(() => import('pages/ManagerSocialJoin/ManagerSocialJoin')); const ManagerLogin = React.lazy(() => import('pages/ManagerLogin/ManagerLogin')); const ManagerMain = React.lazy(() => import('pages/ManagerMain/ManagerMain')); const ManagerMapEditor = React.lazy(() => import('pages/ManagerMapEditor/ManagerMapEditor')); const ManagerReservation = React.lazy(() => import('pages/ManagerReservation/ManagerReservation')); const ManagerSpaceEditor = React.lazy(() => import('pages/ManagerSpaceEditor/ManagerSpaceEditor')); +const GithubOAuthRedirect = React.lazy(() => import('pages/OAuthRedirect/GithubOAuthRedirect')); +const GoogleOAuthRedirect = React.lazy(() => import('pages/OAuthRedirect/GoogleOAuthRedirect')); interface Route { path: string; @@ -33,6 +36,18 @@ export const PUBLIC_ROUTES: Route[] = [ path: PATH.MANAGER_JOIN, component: , }, + { + path: PATH.MANAGER_SOCIAL_JOIN, + component: , + }, + { + path: PATH.MANAGER_GITHUB_OAUTH_REDIRECT, + component: , + }, + { + path: PATH.MANAGER_GOOGLE_OAUTH_REDIRECT, + component: , + }, { path: PATH.GUEST_MAP, component: , diff --git a/frontend/src/hooks/query/useGithubLogin.ts b/frontend/src/hooks/query/useGithubLogin.ts new file mode 100644 index 000000000..ba670ab35 --- /dev/null +++ b/frontend/src/hooks/query/useGithubLogin.ts @@ -0,0 +1,21 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { queryGithubLogin, SocialLoginParams } from 'api/login'; +import { LoginSuccess, SocialLoginFailure } from 'types/response'; + +const useGithubLogin = >( + { code }: SocialLoginParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, SocialLoginParams] + > +): UseQueryResult> => + useQuery(['getGithubLogin', { code }], queryGithubLogin, { + ...options, + retry: false, + refetchOnWindowFocus: false, + }); + +export default useGithubLogin; diff --git a/frontend/src/hooks/query/useGoogleLogin.ts b/frontend/src/hooks/query/useGoogleLogin.ts new file mode 100644 index 000000000..836e6a45f --- /dev/null +++ b/frontend/src/hooks/query/useGoogleLogin.ts @@ -0,0 +1,21 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { queryGoogleLogin, SocialLoginParams } from 'api/login'; +import { SocialLoginFailure, LoginSuccess } from 'types/response'; + +const useGoogleLogin = >( + { code }: SocialLoginParams, + options?: UseQueryOptions< + AxiosResponse, + AxiosError, + TData, + [QueryKey, SocialLoginParams] + > +): UseQueryResult> => + useQuery(['getGoogleLogin', { code }], queryGoogleLogin, { + ...options, + retry: false, + refetchOnWindowFocus: false, + }); + +export default useGoogleLogin; diff --git a/frontend/src/hooks/useQueryString.ts b/frontend/src/hooks/useQueryString.ts new file mode 100644 index 000000000..6151c3236 --- /dev/null +++ b/frontend/src/hooks/useQueryString.ts @@ -0,0 +1,5 @@ +import { useLocation } from 'react-router-dom'; + +const useQueryString = (): URLSearchParams => new URLSearchParams(useLocation().search); + +export default useQueryString; diff --git a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx index 01b14ac67..71bb8d4bf 100644 --- a/frontend/src/pages/ManagerLogin/ManagerLogin.tsx +++ b/frontend/src/pages/ManagerLogin/ManagerLogin.tsx @@ -7,7 +7,7 @@ import { useSetRecoilState } from 'recoil'; import { postLogin } from 'api/login'; import Header from 'components/Header/Header'; import Layout from 'components/Layout/Layout'; -import SocialLoginButton from 'components/SocialLoginButton/SocialLoginButton'; +import SocialLoginButton from 'components/SocialAuthButton/SocialLoginButton'; import MESSAGE from 'constants/message'; import PATH from 'constants/path'; import { LOCAL_STORAGE_KEY } from 'constants/storage'; @@ -74,8 +74,8 @@ const ManagerLogin = (): JSX.Element => { - - + + 아직 회원이 아니신가요? diff --git a/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.styles.ts b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.styles.ts new file mode 100644 index 000000000..4f1e84796 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.styles.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + width: 100%; + max-width: 660px; + margin: 0 auto; +`; + +export const PageTitle = styled.h2` + font-size: 1.5rem; + font-weight: 400; + text-align: center; + margin: 2.125rem auto; +`; diff --git a/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx new file mode 100644 index 000000000..a05d369c8 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx @@ -0,0 +1,63 @@ +import { AxiosError } from 'axios'; +import { useMutation } from 'react-query'; +import { useHistory, useLocation } from 'react-router-dom'; +import { postSocialJoin } from 'api/join'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import MESSAGE from 'constants/message'; +import PATH from 'constants/path'; +import { ErrorResponse } from 'types/response'; +import * as Styled from './ManagerSocialJoin.styles'; +import SocialJoinForm from './units/SocialJoinForm'; + +export interface SocialJoinParams { + email: string; + organization: string; +} + +interface SocialJoinState { + email: string; + oauthProvider: 'GITHUB' | 'GOOGLE'; +} + +const ManagerSocialJoin = (): JSX.Element => { + const history = useHistory(); + const location = useLocation(); + + const email = location.state?.email; + const oauthProvider = location.state?.oauthProvider; + + const socialJoin = useMutation(postSocialJoin, { + onSuccess: () => { + history.replace(PATH.MANAGER_LOGIN); + }, + + onError: (error: AxiosError) => { + alert(error?.response?.data.message ?? MESSAGE.JOIN.FAILURE); + }, + }); + + const handleSubmit = ({ email, organization }: SocialJoinParams) => { + if (!email || !organization || !oauthProvider || socialJoin.isLoading) return; + + socialJoin.mutate({ email, organization, oauthProvider }); + }; + + if (!email || !oauthProvider) { + history.replace(PATH.MANAGER_LOGIN); + } + + return ( + <> +
+ + + 추가 정보 입력 + + + + + ); +}; + +export default ManagerSocialJoin; diff --git a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts new file mode 100644 index 000000000..32813ffb5 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const Form = styled.form` + margin: 3.75rem 0 1rem; + + label { + margin-bottom: 3rem; + } +`; diff --git a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx new file mode 100644 index 000000000..96c97c1f0 --- /dev/null +++ b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx @@ -0,0 +1,70 @@ +import { FormEventHandler, useEffect, useState } from 'react'; +import Input from 'components/Input/Input'; +import SocialJoinButton from 'components/SocialAuthButton/SocialJoinButton'; +import MANAGER from 'constants/manager'; +import MESSAGE from 'constants/message'; +import REGEXP from 'constants/regexp'; +import useInput from 'hooks/useInput'; +import { SocialJoinParams } from '../ManagerSocialJoin'; +import * as Styled from './SocialJoinForm.styles'; + +interface Props { + email: string; + oauthProvider: 'GITHUB' | 'GOOGLE'; + onSubmit: ({ email, organization }: SocialJoinParams) => void; +} + +const SocialJoinForm = ({ email, oauthProvider, onSubmit }: Props): JSX.Element => { + const [organization, onChangeForm] = useInput(''); + + const [organizationMessage, setOrganizationMessage] = useState(''); + + const isValidOrganization = REGEXP.ORGANIZATION.test(organization); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + onSubmit({ email, organization }); + }; + + useEffect(() => { + if (!organization) { + setOrganizationMessage(''); + + return; + } + + setOrganizationMessage( + isValidOrganization ? MESSAGE.JOIN.VALID_ORGANIZATION : MESSAGE.JOIN.INVALID_ORGANIZATION + ); + }, [organization, isValidOrganization]); + + return ( + + + + + + ); +}; + +export default SocialJoinForm; diff --git a/frontend/src/pages/OAuthRedirect/GithubOAuthRedirect.tsx b/frontend/src/pages/OAuthRedirect/GithubOAuthRedirect.tsx new file mode 100644 index 000000000..79f0dee3a --- /dev/null +++ b/frontend/src/pages/OAuthRedirect/GithubOAuthRedirect.tsx @@ -0,0 +1,55 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { useHistory } from 'react-router'; +import { useSetRecoilState } from 'recoil'; +import MESSAGE from 'constants/message'; +import PATH from 'constants/path'; +import { LOCAL_STORAGE_KEY } from 'constants/storage'; +import useGithubLogin from 'hooks/query/useGithubLogin'; +import useQueryString from 'hooks/useQueryString'; +import accessTokenState from 'state/accessTokenState'; +import { LoginSuccess, SocialLoginFailure } from 'types/response'; +import { setLocalStorageItem } from 'utils/localStorage'; + +const GithubOAuthRedirect = (): JSX.Element => { + const history = useHistory(); + const setAccessToken = useSetRecoilState(accessTokenState); + + const query = useQueryString(); + const code = query.get('code') ?? ''; + + useGithubLogin( + { code }, + { + onSuccess: (response: AxiosResponse) => { + const { accessToken } = response.data; + + setAccessToken(accessToken); + setLocalStorageItem({ key: LOCAL_STORAGE_KEY.ACCESS_TOKEN, item: accessToken }); + + history.replace(PATH.MANAGER_MAIN); + }, + + onError: (error: AxiosError) => { + if (error.response?.status === 404) { + history.push({ + pathname: PATH.MANAGER_SOCIAL_JOIN, + state: { + oauthProvider: 'GITHUB', + email: error.response?.data?.email, + }, + }); + + return; + } + + alert(error.response?.data.message ?? MESSAGE.LOGIN.UNEXPECTED_ERROR); + + history.replace(PATH.MANAGER_LOGIN); + }, + } + ); + + return
; +}; + +export default GithubOAuthRedirect; diff --git a/frontend/src/pages/OAuthRedirect/GoogleOAuthRedirect.tsx b/frontend/src/pages/OAuthRedirect/GoogleOAuthRedirect.tsx new file mode 100644 index 000000000..a19619a5b --- /dev/null +++ b/frontend/src/pages/OAuthRedirect/GoogleOAuthRedirect.tsx @@ -0,0 +1,55 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { useHistory } from 'react-router'; +import { useSetRecoilState } from 'recoil'; +import MESSAGE from 'constants/message'; +import PATH from 'constants/path'; +import { LOCAL_STORAGE_KEY } from 'constants/storage'; +import useGoogleLogin from 'hooks/query/useGoogleLogin'; +import useQueryString from 'hooks/useQueryString'; +import accessTokenState from 'state/accessTokenState'; +import { SocialLoginFailure, LoginSuccess } from 'types/response'; +import { setLocalStorageItem } from 'utils/localStorage'; + +const GoogleOAuthRedirect = (): JSX.Element => { + const history = useHistory(); + const setAccessToken = useSetRecoilState(accessTokenState); + + const query = useQueryString(); + const code = query.get('code') ?? ''; + + useGoogleLogin( + { code }, + { + onSuccess: (response: AxiosResponse) => { + const { accessToken } = response.data; + + setAccessToken(accessToken); + setLocalStorageItem({ key: LOCAL_STORAGE_KEY.ACCESS_TOKEN, item: accessToken }); + + history.replace(PATH.MANAGER_MAIN); + }, + + onError: (error: AxiosError) => { + if (error.response?.status === 404) { + history.push({ + pathname: PATH.MANAGER_SOCIAL_JOIN, + state: { + oauthProvider: 'GOOGLE', + email: error.response?.data?.email, + }, + }); + + return; + } + + alert(error.response?.data.message ?? MESSAGE.LOGIN.UNEXPECTED_ERROR); + + history.replace(PATH.MANAGER_LOGIN); + }, + } + ); + + return
; +}; + +export default GoogleOAuthRedirect; diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 396cd0ede..841da3d3b 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -17,6 +17,16 @@ export interface LoginSuccess { accessToken: string; } +export interface SocialLoginFailure { + message?: string; + email?: string; +} + +export interface QuerySocialEmailSuccess { + email: string; + oauthProvider: 'GITHUB' | 'GOOGLE'; +} + export type QueryGuestMapSuccess = MapItemResponse; export type QueryManagerMapSuccess = MapItemResponse; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index ddf91bd53..c718960aa 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -3,6 +3,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); const webpack = require('webpack'); +const Dotenv = require('dotenv-webpack'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = () => { @@ -80,6 +81,7 @@ module.exports = () => { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 'process.env.DEPLOY_ENV': JSON.stringify(process.env.DEPLOY_ENV), }), + new Dotenv(), new BundleAnalyzerPlugin({ analyzerMode: isDevelopment ? 'server' : 'static', }), diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2aad4cb3f..4f0d627ae 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6304,6 +6304,13 @@ dotenv-defaults@^1.0.2: dependencies: dotenv "^6.2.0" +dotenv-defaults@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-2.0.2.tgz#6b3ec2e4319aafb70940abda72d3856770ee77ac" + integrity sha512-iOIzovWfsUHU91L5i8bJce3NYK5JXeAwH50Jh6+ARUdLiiGlYWfGw6UkzsYqaXZH/hjE/eCd/PlfM/qqyK0AMg== + dependencies: + dotenv "^8.2.0" + dotenv-expand@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" @@ -6316,12 +6323,19 @@ dotenv-webpack@^1.8.0: dependencies: dotenv-defaults "^1.0.2" +dotenv-webpack@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/dotenv-webpack/-/dotenv-webpack-7.0.3.tgz#f50ec3c7083a69ec6076e110566720003b7b107b" + integrity sha512-O0O9pOEwrk+n1zzR3T2uuXRlw64QxHSPeNN1GaiNBloQFNaCUL9V8jxSVz4jlXXFP/CIqK8YecWf8BAvsSgMjw== + dependencies: + dotenv-defaults "^2.0.2" + dotenv@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064" integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w== -dotenv@^8.0.0: +dotenv@^8.0.0, dotenv@^8.2.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== From 83fdca47d5e5f815a6c9834753a34a0c7a03a20c Mon Sep 17 00:00:00 2001 From: Sunny K Date: Fri, 17 Sep 2021 10:46:30 +0900 Subject: [PATCH 19/25] =?UTF-8?q?fix:=20=ED=94=84=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=EC=9D=B4=20=EC=84=A4=EC=A0=95=EB=90=9C=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8B=A4=EB=A5=B8=20=EA=B3=B5=EA=B0=84=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=ED=94=84=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=EC=9D=B4=20=EC=B4=88=EA=B8=B0=ED=99=94=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20(#562)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx | 2 ++ frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx b/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx index 74eca0ace..82f1123f3 100644 --- a/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx +++ b/frontend/src/pages/ManagerSpaceEditor/providers/SpaceFormProvider.tsx @@ -57,6 +57,7 @@ const SpaceFormProvider = ({ children }: Props): JSX.Element => { (weekday) => (nextEnableWeekdays[weekday] = enableWeekdays.includes(weekday)) ); + setSelectedPresetId(null); setValues({ name, color, @@ -120,6 +121,7 @@ const SpaceFormProvider = ({ children }: Props): JSX.Element => { }; const resetForm = () => { + setSelectedPresetId(null); setValues({ ...initialSpaceFormValue, enabledWeekdays: initialEnabledWeekdays, area: null }); }; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx index 128f321e4..2194878c3 100644 --- a/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx +++ b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx @@ -55,8 +55,6 @@ const Preset = (): JSX.Element => { }); const handleSelectPreset = (id: number | null) => { - setSelectedPresetId(id); - if (id === null) return; const selectedPreset = presets.find((preset) => preset.id === id) ?? null; @@ -86,6 +84,8 @@ const Preset = (): JSX.Element => { reservationMaximumTimeUnit, enabledWeekdays: enabledWeekdays as SpaceFormValue['enabledWeekdays'], }); + + setSelectedPresetId(id); }; const handleAddPreset = () => { From e99d36660f9f05d5e5882eb68183d5e863ecd3d1 Mon Sep 17 00:00:00 2001 From: Sunny K Date: Fri, 17 Sep 2021 10:46:50 +0900 Subject: [PATCH 20/25] build: version 1.0.2 (#563) --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 6ce765005..caf364865 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "zzimkkong-frontend", - "version": "1.0.1", + "version": "1.0.2", "main": "src/index.tsx", "license": "MIT", "homepage": "https://github.com/woowacourse-teams/2021-zzimkkong", From 48678b9d9c2a26c07515facac23399aa4b38170e Mon Sep 17 00:00:00 2001 From: Kimun Kim Date: Fri, 17 Sep 2021 16:22:02 +0900 Subject: [PATCH 21/25] =?UTF-8?q?fix:=20oauth=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=ED=95=B4=EA=B2=B0=ED=95=9C=EB=8B=A4.=20OAuth?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=9C=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=94=8C=EB=A1=9C=EC=9A=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4.=20(#561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: google redirect uri property에 따라 동작하고로 수정, dataloader 변경 * refactor: github 예외 처리 로직 추가 * refactor: 서브모듈 업데이트 * refactor: test 환경 redirect uri 추가 * refactor: github 예외 테스트 추가 * chore: 서브 모듈 최신화 * feat: OAuth 회원가입, 로그인 변경된 시나리오에 대한 요구사항 반영 - 테스트 실패 문제는 추후 해결합니다. * fix: DataLoader 변경사항 반영 * refactor: NoSuchOAuthMemberException 응답코드 404로 수정 * refactor: 변경된 시나리오에 따른 테스트 반영 * test: GoogleRequester의 예외 상황 테스트 작성 * refactor: OAuth 관련 클래스명, 예외 메세지의 일부 변경 * refactor: ErrorResponseToGetGithubAccessTokenException 네이밍 변경 - ErrorResponseToGetAccessTokenException 으로 변경합니다. * feat: OAuth 회원가입 시 이메일 중복 검사 로직 생성 * refactor: GoogleRequesterTest 관련 잘못된 클래스명, 변수명 수정 * feat: OAuth 회원가입 과정에서 이메일 중복시 OAuthProvider 일치 여부에 따라 상이하게 응답 * refactor: 자동 생성된 Api 문서 레포지토리에서 삭제 * refactor: DuplicateEmailInOAuthFlowException 중복으로 인해 삭제 * refactor: handleOAuthLoginFailHandler 네이밍 변경 handler의 중복 사용으로 인해 변경합니다. * feat: OAuth 회원가입 시도시 이미 가입된 메일인 경우, OAuthProvider를 비교하여 에러 메세지 응답 * feat: DuplicateEmailInOAuthFlowException 생성 회원가입 플로우에서 이미 가입된 이메일로 가입하려 하는 경우 발생시키는 예외입니다. * refactor: GoogleRequesterTest 내의 잘못된 인스턴스명 수정 * refactor: Auth Code를 이용해 이메일을 얻어오는 API 삭제 회원 미가입으로 인한 OAuth 로그인 실패시 필요한 정보를 같이 응답하므로 이제 필요없습니다. * refactor: DuplicateEmailInOAuthFlowException 미사용으로 인해 삭제 * test: NoSuchOAuthMemberException 테스트 커버리지 인상 * refactor: OAuthLoginFailErrorResponse 생성자 접근제어자 private로 변경 Co-authored-by: Jungseok Sung <58401309+sakjung@users.noreply.github.com> Co-authored-by: y_woo :) Co-authored-by: Jungseok Sung <58401309+sakjung@users.noreply.github.com> --- .../com/woowacourse/zzimkkong/DataLoader.java | 5 ++ .../controller/ControllerAdvice.java | 10 ++++ .../controller/MemberController.java | 12 +--- .../dto/OAuthLoginFailErrorResponse.java | 19 +++++++ .../dto/member/oauth/OauthReadyResponse.java | 21 ------- .../OauthProviderMismatchException.java | 17 +++++- ...rorResponseToGetAccessTokenException.java} | 4 +- .../member/NoSuchOAuthMemberException.java | 17 ++++++ .../infrastructure/oauth/GithubRequester.java | 4 +- .../infrastructure/oauth/GoogleRequester.java | 9 ++- .../zzimkkong/service/AuthService.java | 6 +- .../zzimkkong/service/MemberService.java | 19 ++----- .../controller/MemberControllerTest.java | 55 +------------------ .../oauth/GithubRequesterTest.java | 4 +- .../oauth/GoogleRequesterTest.java | 48 +++++++++++++++- .../oauth/OauthHandlerTest.java | 3 +- .../zzimkkong/service/AuthServiceTest.java | 25 ++++++--- .../zzimkkong/service/MemberServiceTest.java | 48 +--------------- 18 files changed, 157 insertions(+), 169 deletions(-) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/OAuthLoginFailErrorResponse.java delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java rename backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/{ErrorResponseToGetGithubAccessTokenException.java => ErrorResponseToGetAccessTokenException.java} (68%) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchOAuthMemberException.java diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java index 111eabb68..7366609ab 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java @@ -177,6 +177,7 @@ public void run(String... args) { Reservation reservationBackEndTargetDate0To1 = Reservation.builder() .startTime(targetDate.atStartOfDay()) .endTime(targetDate.atTime(1, 0, 0)) + .date(targetDate) .description("찜꽁 1차 회의") .userName("찜꽁") .password("1234") @@ -186,6 +187,7 @@ public void run(String... args) { Reservation reservationBackEndTargetDate13To14 = Reservation.builder() .startTime(targetDate.atTime(13, 0, 0)) .endTime(targetDate.atTime(14, 0, 0)) + .date(targetDate) .description("찜꽁 2차 회의") .userName("찜꽁") .password("1234") @@ -195,6 +197,7 @@ public void run(String... args) { Reservation reservationBackEndTargetDate18To23 = Reservation.builder() .startTime(targetDate.atTime(18, 0, 0)) .endTime(targetDate.atTime(23, 59, 59)) + .date(targetDate) .description("찜꽁 3차 회의") .userName("찜꽁") .password("6789") @@ -204,6 +207,7 @@ public void run(String... args) { Reservation reservationBackEndTheDayAfterTargetDate = Reservation.builder() .startTime(targetDate.plusDays(1L).atStartOfDay()) .endTime(targetDate.plusDays(1L).atTime(1, 0, 0)) + .date(targetDate) .description("찜꽁 4차 회의") .userName("찜꽁") .password("1234") @@ -213,6 +217,7 @@ public void run(String... args) { Reservation reservationFrontEnd1TargetDate0to1 = Reservation.builder() .startTime(targetDate.atStartOfDay()) .endTime(targetDate.atTime(1, 0, 0)) + .date(targetDate) .description("찜꽁 5차 회의") .userName("찜꽁") .password("1234") diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java index 4a47b2bda..3a3af95b2 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java @@ -3,9 +3,11 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.woowacourse.zzimkkong.dto.ErrorResponse; import com.woowacourse.zzimkkong.dto.InputFieldErrorResponse; +import com.woowacourse.zzimkkong.dto.OAuthLoginFailErrorResponse; import com.woowacourse.zzimkkong.exception.InputFieldException; import com.woowacourse.zzimkkong.exception.ZzimkkongException; import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataAccessException; @@ -24,6 +26,14 @@ public class ControllerAdvice { private final Logger logger = LoggerFactory.getLogger(ControllerAdvice.class); + @ExceptionHandler(NoSuchOAuthMemberException.class) + public ResponseEntity oAuthLoginFailHandler(final NoSuchOAuthMemberException exception) { + logger.info(exception.getMessage()); + return ResponseEntity + .status(exception.getStatus()) + .body(OAuthLoginFailErrorResponse.from(exception)); + } + @ExceptionHandler(InputFieldException.class) public ResponseEntity inputFieldExceptionHandler(final InputFieldException exception) { logger.info(exception.getMessage()); diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java index 03449acda..a6e9c67db 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java @@ -2,10 +2,8 @@ import com.woowacourse.zzimkkong.domain.Manager; import com.woowacourse.zzimkkong.domain.Member; -import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.dto.member.*; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; -import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.service.MemberService; import com.woowacourse.zzimkkong.service.PresetService; import org.springframework.http.ResponseEntity; @@ -40,13 +38,6 @@ public ResponseEntity join(@RequestBody @Valid final MemberSaveRequest mem .build(); } - @GetMapping("/{oauthProvider}") - public ResponseEntity getReadyToJoinByOauth(@PathVariable OauthProvider oauthProvider, @RequestParam String code) { - OauthReadyResponse oauthReadyResponse = memberService.getUserInfoFromOauth(oauthProvider, code); - return ResponseEntity - .ok(oauthReadyResponse); - } - @PostMapping("/oauth") public ResponseEntity joinByOauth(@RequestBody @Valid final OauthMemberSaveRequest oauthMemberSaveRequest) { MemberSaveResponse memberSaveResponse = memberService.saveMemberByOauth(oauthMemberSaveRequest); @@ -59,8 +50,7 @@ public ResponseEntity joinByOauth(@RequestBody @Valid final OauthMemberSav public ResponseEntity validateEmail( @RequestParam @NotBlank(message = EMPTY_MESSAGE) - @Email(message = EMAIL_MESSAGE) - final String email) { + @Email(message = EMAIL_MESSAGE) final String email) { memberService.validateDuplicateEmail(email); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/OAuthLoginFailErrorResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/OAuthLoginFailErrorResponse.java new file mode 100644 index 000000000..7d45c7055 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/OAuthLoginFailErrorResponse.java @@ -0,0 +1,19 @@ +package com.woowacourse.zzimkkong.dto; + +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; +import lombok.Getter; + +@Getter +public class OAuthLoginFailErrorResponse extends ErrorResponse { + private final String email; + + private OAuthLoginFailErrorResponse(String message, String email) { + super(message); + this.email = email; + } + + public static OAuthLoginFailErrorResponse from(NoSuchOAuthMemberException exception) { + String email = exception.getEmail(); + return new OAuthLoginFailErrorResponse(exception.getMessage(), email); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java deleted file mode 100644 index ba6f1e417..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/oauth/OauthReadyResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.woowacourse.zzimkkong.dto.member.oauth; - -import com.woowacourse.zzimkkong.domain.OauthProvider; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class OauthReadyResponse { - private String email; - private OauthProvider oauthProvider; - - private OauthReadyResponse(final String email, final OauthProvider oauthProvider) { - this.email = email; - this.oauthProvider = oauthProvider; - } - - public static OauthReadyResponse of(final String email, final OauthProvider oauthProvider) { - return new OauthReadyResponse(email, oauthProvider); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java index cb145ff9a..1d1ab7d02 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java @@ -1,12 +1,25 @@ package com.woowacourse.zzimkkong.exception.authorization; +import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.exception.ZzimkkongException; import org.springframework.http.HttpStatus; public class OauthProviderMismatchException extends ZzimkkongException { - private static final String MESSAGE = "소셜 로그인 제공자가 다릅니다. %s를 통해 로그인하세요."; + private static final String MESSAGE_FORMAT = "소셜 로그인 제공자가 다릅니다. %s를 통해 로그인하세요."; public OauthProviderMismatchException(final String oauthProvider) { - super(String.format(MESSAGE, oauthProvider), HttpStatus.UNAUTHORIZED); + super(String.format(MESSAGE_FORMAT, oauthProvider), HttpStatus.UNAUTHORIZED); + } + + public static OauthProviderMismatchException from(OauthProvider oauthProvider) { + String message = formatMessage(oauthProvider); + return new OauthProviderMismatchException(message); + } + + private static String formatMessage(OauthProvider oauthProvider) { + if (oauthProvider != null) { + return String.format(MESSAGE_FORMAT, oauthProvider.name()); + } + return String.format(MESSAGE_FORMAT, "이메일/비밀번호"); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetGithubAccessTokenException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetAccessTokenException.java similarity index 68% rename from backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetGithubAccessTokenException.java rename to backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetAccessTokenException.java index f65f74cf4..0637f2d0b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetGithubAccessTokenException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/oauth/ErrorResponseToGetAccessTokenException.java @@ -3,10 +3,10 @@ import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; import org.springframework.http.HttpStatus; -public class ErrorResponseToGetGithubAccessTokenException extends InfrastructureMalfunctionException { +public class ErrorResponseToGetAccessTokenException extends InfrastructureMalfunctionException { private static final String MESSAGE = "소셜 로그인에 실패했습니다. 다시 시도해주세요."; - public ErrorResponseToGetGithubAccessTokenException(String errorMessageFromGithub) { + public ErrorResponseToGetAccessTokenException(String errorMessageFromGithub) { super(MESSAGE, new Throwable(errorMessageFromGithub), HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchOAuthMemberException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchOAuthMemberException.java new file mode 100644 index 000000000..70e69ffc7 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/NoSuchOAuthMemberException.java @@ -0,0 +1,17 @@ +package com.woowacourse.zzimkkong.exception.member; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class NoSuchOAuthMemberException extends ZzimkkongException { + private static final String MESSAGE = "소셜 로그인 회원이 아닙니다. 회원가입을 진행합니다."; + + private final String email; + + public NoSuchOAuthMemberException(String email) { + super(MESSAGE, HttpStatus.NOT_FOUND); + this.email = email; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java index c0dfa8218..7083eaee9 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java @@ -3,7 +3,7 @@ import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; -import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetGithubAccessTokenException; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; import com.woowacourse.zzimkkong.exception.infrastructure.oauth.UnableToGetTokenResponseFromGithubException; import org.springframework.context.annotation.PropertySource; import org.springframework.core.ParameterizedTypeReference; @@ -62,7 +62,7 @@ private String getToken(final String code) { private void validateResponseBody(Map responseBody) { if (!responseBody.containsKey("access_token")) { - throw new ErrorResponseToGetGithubAccessTokenException(responseBody.get("error_description").toString()); + throw new ErrorResponseToGetAccessTokenException(responseBody.get("error_description").toString()); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java index 24b22c1a4..41b66bb85 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java @@ -3,6 +3,7 @@ import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; import com.woowacourse.zzimkkong.exception.infrastructure.oauth.UnableToGetTokenResponseFromGoogleException; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.PropertySource; @@ -68,10 +69,16 @@ private String getToken(final String code) { }) .blockOptional() .orElseThrow(UnableToGetTokenResponseFromGoogleException::new); - + validateResponseBody(responseBody); return responseBody.get("access_token").toString(); } + private void validateResponseBody(Map responseBody) { + if (!responseBody.containsKey("access_token")) { + throw new ErrorResponseToGetAccessTokenException(responseBody.get("error_description").toString()); + } + } + private GoogleUserInfo getUserInfo(final String token) { Map responseBody = googleUserClient() .get() diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java index 619214041..6ddb6bfae 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java @@ -7,6 +7,7 @@ import com.woowacourse.zzimkkong.dto.member.TokenResponse; import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; import com.woowacourse.zzimkkong.infrastructure.JwtUtils; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; @@ -51,8 +52,9 @@ public TokenResponse login(final LoginRequest loginRequest) { public TokenResponse loginByOauth(final OauthProvider oauthProvider, final String code) { OauthUserInfo userInfoFromCode = oauthHandler.getUserInfoFromCode(oauthProvider, code); String email = userInfoFromCode.getEmail(); + Member member = members.findByEmail(email) - .orElseThrow(NoSuchMemberException::new); + .orElseThrow(() -> new NoSuchOAuthMemberException(email)); validateOauthProvider(oauthProvider, member); @@ -77,7 +79,7 @@ private void validatePassword(final Member findMember, final String password) { private void validateOauthProvider(final OauthProvider oauthProvider, final Member member) { OauthProvider memberOauthProvider = member.getOauthProvider(); if (!oauthProvider.equals(memberOauthProvider)) { - throw new OauthProviderMismatchException(memberOauthProvider.name()); + throw OauthProviderMismatchException.from(memberOauthProvider); } } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java index 8ebe0e101..bee87ce2e 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/MemberService.java @@ -2,12 +2,10 @@ import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.domain.OauthProvider; -import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; import com.woowacourse.zzimkkong.dto.member.MemberUpdateRequest; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; -import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; import com.woowacourse.zzimkkong.exception.member.ReservationExistsOnMemberException; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; @@ -48,23 +46,16 @@ public MemberSaveResponse saveMember(final MemberSaveRequest memberSaveRequest) return MemberSaveResponse.from(saveMember); } - @Transactional(readOnly = true) - public OauthReadyResponse getUserInfoFromOauth(final OauthProvider oauthProvider, final String code) { - OauthUserInfo userInfo = oauthHandler.getUserInfoFromCode(oauthProvider, code); - String email = userInfo.getEmail(); + public MemberSaveResponse saveMemberByOauth(final OauthMemberSaveRequest oauthMemberSaveRequest) { + String email = oauthMemberSaveRequest.getEmail(); + OauthProvider oauthProvider = OauthProvider.valueOfWithIgnoreCase(oauthMemberSaveRequest.getOauthProvider()); validateDuplicateEmail(email); - return OauthReadyResponse.of(email, oauthProvider); - } - - public MemberSaveResponse saveMemberByOauth(final OauthMemberSaveRequest oauthMemberSaveRequest) { - validateDuplicateEmail(oauthMemberSaveRequest.getEmail()); - Member member = new Member( - oauthMemberSaveRequest.getEmail(), + email, oauthMemberSaveRequest.getOrganization(), - OauthProvider.valueOfWithIgnoreCase(oauthMemberSaveRequest.getOauthProvider()) + oauthProvider ); Member saveMember = members.save(member); return MemberSaveResponse.from(saveMember); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java index 89a8bbadf..ce311c4c1 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java @@ -4,14 +4,10 @@ import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.Preset; import com.woowacourse.zzimkkong.domain.Setting; -import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; -import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; -import com.woowacourse.zzimkkong.dto.member.*; -import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; -import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.dto.ErrorResponse; import com.woowacourse.zzimkkong.dto.InputFieldErrorResponse; import com.woowacourse.zzimkkong.dto.member.*; +import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.infrastructure.AuthorizationExtractor; import io.restassured.RestAssured; @@ -26,14 +22,10 @@ import org.springframework.http.MediaType; import java.util.List; -import java.util.Map; import static com.woowacourse.zzimkkong.Constants.*; import static com.woowacourse.zzimkkong.DocumentUtils.*; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document; class MemberControllerTest extends AcceptanceTest { @@ -81,51 +73,6 @@ void join() { assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); } - @Test - @DisplayName("Google Oauth 회원가입 입력이 들어오면 accessToken을 발급한다.") - void getReadyToJoinByGoogleOauth() { - // given - given(googleRequester.supports(any(OauthProvider.class))) - .willReturn(true); - given(googleRequester.getUserInfoByCode(anyString())) - .willReturn(GoogleUserInfo.from( - Map.of("id", "123", - "email", NEW_EMAIL))); - - OauthProvider oauthProvider = OauthProvider.GOOGLE; - String code = "example-code"; - - // when - ExtractableResponse response = getReadyToJoin(oauthProvider, code); - OauthReadyResponse expected = OauthReadyResponse.of(NEW_EMAIL, oauthProvider); - - //then - assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); - assertThat(response.body().as(OauthReadyResponse.class)).usingRecursiveComparison() - .isEqualTo(expected); - } - - @Test - @DisplayName("Github Oauth 회원가입 입력이 들어오면 accessToken을 발급한다.") - void getReadyToJoinByGithubOauth() { - // given - given(githubRequester.supports(any(OauthProvider.class))) - .willReturn(true); - given(githubRequester.getUserInfoByCode(anyString())) - .willReturn(GithubUserInfo.from(Map.of("email", NEW_EMAIL))); - - OauthProvider oauthProvider = OauthProvider.GITHUB; - String code = "example-code"; - - // when - ExtractableResponse response = getReadyToJoin(oauthProvider, code); - OauthReadyResponse oauthReadyResponse = response.as(OauthReadyResponse.class); - - // then - assertThat(oauthReadyResponse.getOauthProvider()).isEqualTo(oauthProvider); - assertThat(oauthReadyResponse.getEmail()).isEqualTo(NEW_EMAIL); - } - @ParameterizedTest @ValueSource(strings = {"GOOGLE", "GITHUB"}) @DisplayName("Oauth을 이용해 회원가입한다.") diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java index ff806cafd..e68ca00a7 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequesterTest.java @@ -2,7 +2,7 @@ import com.woowacourse.zzimkkong.Constants; import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; -import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetGithubAccessTokenException; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.DisplayName; @@ -113,7 +113,7 @@ void getTokenException() { // when, then assertThatThrownBy(() -> githubRequester.getUserInfoByCode("code")) - .isInstanceOf(ErrorResponseToGetGithubAccessTokenException.class); + .isInstanceOf(ErrorResponseToGetAccessTokenException.class); } catch (IOException ignored) { } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java index 8a11af175..e136a7320 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequesterTest.java @@ -2,10 +2,12 @@ import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; +import com.woowacourse.zzimkkong.exception.infrastructure.oauth.ErrorResponseToGetAccessTokenException; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; @@ -13,6 +15,7 @@ import java.io.IOException; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @ActiveProfiles("test") class GoogleRequesterTest { @@ -80,13 +83,52 @@ void supports() { assertThat(googleRequester.supports(OauthProvider.GITHUB)).isFalse(); } - private void setUpResponse(MockWebServer mockGithubServer) { - mockGithubServer.enqueue(new MockResponse() + private void setUpResponse(MockWebServer mockGoogleServer) { + mockGoogleServer.enqueue(new MockResponse() .setBody(GOOGLE_TOKEN_RESPONSE) .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); - mockGithubServer.enqueue(new MockResponse() + mockGoogleServer.enqueue(new MockResponse() .setBody(USER_INFO_RESPONSE_EXAMPLE) .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); } + + @Value("${google.uri.redirect}") + private String redirectUri; + + @Test + @DisplayName("오류가 발생하면 ErrorResponseToGetAccessTokenException을 응답한다.") + void getTokenException() { + String getTokenErrorResponse = "{\n" + + " \"error\": \"redirect_uri_mismatch\",\n" + + " \"error_description\": \"Bad Request\"\n" + + "}"; + + try (MockWebServer mockGoogleServer = new MockWebServer()) { + // given + mockGoogleServer.start(); + + setUpGetTokenResponse(mockGoogleServer, getTokenErrorResponse); + setUpGetTokenResponse(mockGoogleServer, USER_INFO_RESPONSE_EXAMPLE); + + GoogleRequester googleRequester = new GoogleRequester( + "clientId", + "secretId", + this.redirectUri, + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()), + String.format("http://%s:%s", mockGoogleServer.getHostName(), mockGoogleServer.getPort()) + ); + + // when, then + assertThatThrownBy(() -> googleRequester.getUserInfoByCode("code")) + .isInstanceOf(ErrorResponseToGetAccessTokenException.class); + } catch (IOException ignored) { + } + } + + private void setUpGetTokenResponse(MockWebServer mockGoogleServer, String responseExample) { + mockGoogleServer.enqueue(new MockResponse() + .setBody(responseExample) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java index 0aa406970..1e8fb566d 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandlerTest.java @@ -14,7 +14,6 @@ import java.util.Map; -import static com.woowacourse.zzimkkong.infrastructure.oauth.GoogleRequesterTest.SALLY_EMAIL; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; @@ -22,6 +21,8 @@ @SpringBootTest @ActiveProfiles("test") class OauthHandlerTest { + public static final String SALLY_EMAIL = "dusdn1702@gmail.com"; + @Autowired private OauthHandler oauthHandler; diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java index b77356253..9528775e7 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java @@ -7,6 +7,7 @@ import com.woowacourse.zzimkkong.dto.member.TokenResponse; import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; +import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; import com.woowacourse.zzimkkong.infrastructure.JwtUtils; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; @@ -14,12 +15,16 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.core.parameters.P; import java.util.Arrays; import java.util.Optional; +import java.util.stream.Stream; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; @@ -133,19 +138,23 @@ void loginByOauthInvalidEmailException(OauthProvider oauthProvider) { // when, then assertThatThrownBy(() -> authService.loginByOauth(oauthProvider, mockCode)) - .isInstanceOf(NoSuchMemberException.class); + .isInstanceOf(NoSuchOAuthMemberException.class); + } + + static Stream loginByOauthInvalidProviderException() { + return Stream.of( + Arguments.of(OauthProvider.GITHUB, OauthProvider.GOOGLE), + Arguments.of(OauthProvider.GOOGLE, OauthProvider.GITHUB), + Arguments.of(OauthProvider.GOOGLE, null) + ); } @ParameterizedTest - @EnumSource(OauthProvider.class) + @MethodSource @DisplayName("회원가입한 provider와 다른 provider로 같은 이메일 oauth 로그인 시 오류가 발생한다.") - void loginByOauthInvalidProviderException(OauthProvider oauthProvider) { + void loginByOauthInvalidProviderException(OauthProvider oauthProvider, OauthProvider actualOauthProvider) { // given String mockCode = "Mock Code from OauthProvider"; - OauthProvider anotherProvider = Arrays.stream(OauthProvider.values()) - .filter(provider -> !provider.equals(oauthProvider)) - .findAny() - .get(); OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) @@ -153,7 +162,7 @@ void loginByOauthInvalidProviderException(OauthProvider oauthProvider) { given(mockOauthUserInfo.getEmail()) .willReturn(EMAIL); given(members.findByEmail(EMAIL)) - .willReturn(Optional.of(new Member(EMAIL, ORGANIZATION, anotherProvider))); + .willReturn(Optional.of(new Member(EMAIL, ORGANIZATION, actualOauthProvider))); // when, then assertThatThrownBy(() -> authService.loginByOauth(oauthProvider, mockCode)) diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java index 7d0077922..8335f18dc 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/MemberServiceTest.java @@ -1,20 +1,16 @@ package com.woowacourse.zzimkkong.service; import com.woowacourse.zzimkkong.domain.Member; -import com.woowacourse.zzimkkong.domain.OauthProvider; -import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveResponse; import com.woowacourse.zzimkkong.dto.member.MemberUpdateRequest; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; -import com.woowacourse.zzimkkong.dto.member.oauth.OauthReadyResponse; import com.woowacourse.zzimkkong.exception.member.DuplicateEmailException; import com.woowacourse.zzimkkong.exception.member.ReservationExistsOnMemberException; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; @@ -24,9 +20,9 @@ import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; class MemberServiceTest extends ServiceTest { @Autowired @@ -77,46 +73,6 @@ void saveMemberException() { .isInstanceOf(DuplicateEmailException.class); } - @ParameterizedTest - @EnumSource(OauthProvider.class) - @DisplayName("Oauth를 통해 얻을 수 없는 정보를 응답하며 회원가입 과정을 진행한다.") - void getUserInfoFromOauth(OauthProvider oauthProvider) { - //given - OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); - given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) - .willReturn(mockOauthUserInfo); - given(mockOauthUserInfo.getEmail()) - .willReturn(EMAIL); - given(members.existsByEmail(EMAIL)) - .willReturn(false); - - //when - OauthReadyResponse actual = memberService.getUserInfoFromOauth(oauthProvider, "code-example"); - OauthReadyResponse expected = OauthReadyResponse.of(EMAIL, oauthProvider); - - //then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - - @ParameterizedTest - @EnumSource(OauthProvider.class) - @DisplayName("이미 존재하는 이메일로 oauth 정보를 가져오면 에러가 발생한다.") - void getUserInfoFromOauthException(OauthProvider oauthProvider) { - //given - OauthUserInfo mockOauthUserInfo = mock(OauthUserInfo.class); - given(oauthHandler.getUserInfoFromCode(any(OauthProvider.class), anyString())) - .willReturn(mockOauthUserInfo); - given(mockOauthUserInfo.getEmail()) - .willReturn(EMAIL); - given(members.existsByEmail(EMAIL)) - .willReturn(true); - - //when, then - assertThatThrownBy(() -> memberService.getUserInfoFromOauth(oauthProvider, "code-example")) - .isInstanceOf(DuplicateEmailException.class); - } - @ParameterizedTest @ValueSource(strings = {"GOOGLE", "GITHUB"}) @DisplayName("소셜 로그인을 이용해 회원가입한다.") From f75c8b790a424a3038e4826de3a42a6b6bc09d11 Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Fri, 17 Sep 2021 17:01:32 +0900 Subject: [PATCH 22/25] =?UTF-8?q?fix:=20OAuth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=20Redirect=20URI=EC=9D=B4=20=EC=A0=9C?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=84=A4=EC=A0=95=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#566)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/path.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 8c3b08661..eaf74b626 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -16,6 +16,10 @@ const GITHUB_OAUTH_KEY = (() => { const GOOGLE_OAUTH_KEY = process.env.GOOGLE_OAUTH_KEY ?? ''; +const REDIRECT_URI = `${process.env.NODE_ENV === 'production' ? 'https://' : 'http://'}${ + window.location.hostname +}${window.location.port ? `:${window.location.port}` : ''}`; + const PATH = { MAIN: '/', MANAGER_LOGIN: '/login', @@ -33,7 +37,7 @@ const PATH = { GUEST_RESERVATION: '/guest/:sharingMapId/reservation', GUEST_RESERVATION_EDIT: '/guest/:sharingMapId/reservation/edit', NOT_FOUND: '/not-found', - GITHUB_LOGIN: `https://github.com/login/oauth/authorize?client_id=${GITHUB_OAUTH_KEY}&redirect_uri=http://localhost:3000/login/oauth/github`, + GITHUB_LOGIN: `https://github.com/login/oauth/authorize?client_id=${GITHUB_OAUTH_KEY}&redirect_uri=${REDIRECT_URI}/login/oauth/github`, GOOGLE_LOGIN: 'https://accounts.google.com/o/oauth2/v2/auth?' + 'scope=https://www.googleapis.com/auth/userinfo.email&' + @@ -41,7 +45,7 @@ const PATH = { 'include_granted_scopes=true&' + 'response_type=code&' + 'state=state_parameter_passthrough_value&' + - 'redirect_uri=http://localhost:3000/login/oauth/google&' + + `redirect_uri=${REDIRECT_URI}/login/oauth/google&` + `client_id=${GOOGLE_OAUTH_KEY}`, }; From ba5e161ce0747e097bcad9aa8171f624b05cafc1 Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Fri, 17 Sep 2021 21:53:21 +0900 Subject: [PATCH 23/25] =?UTF-8?q?fix:=20OAuth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20URL=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=98=EC=96=B4=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20(#570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/join.ts | 2 +- frontend/src/api/login.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/join.ts b/frontend/src/api/join.ts index 9bd791221..dfedbd89b 100644 --- a/frontend/src/api/join.ts +++ b/frontend/src/api/join.ts @@ -36,7 +36,7 @@ export const postSocialJoin = ({ organization, oauthProvider, }: SocialJoinParams): Promise => - api.post(`/api/managers/oauth`, { + api.post(`/managers/oauth`, { email, organization, oauthProvider, diff --git a/frontend/src/api/login.ts b/frontend/src/api/login.ts index 2a2e450f1..48d59ddd5 100644 --- a/frontend/src/api/login.ts +++ b/frontend/src/api/login.ts @@ -31,5 +31,5 @@ export const queryGoogleLogin: QueryFunction< > = ({ queryKey }) => { const [, { code }] = queryKey; - return api.get(`/api/managers/google/login/token?code=${code}`); + return api.get(`/managers/google/login/token?code=${code}`); }; From 6ac2be6fd88d4319e26ebf085009c9a3a84603fc Mon Sep 17 00:00:00 2001 From: Kimun Kim Date: Fri, 17 Sep 2021 22:17:13 +0900 Subject: [PATCH 24/25] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B3=B5=EC=82=AC=EB=A1=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=ED=95=A0=EC=8B=9C=20=EB=A9=94=EC=84=B8=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=20=ED=8F=AC=EB=A7=A4=ED=8C=85=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: google redirect uri property에 따라 동작하고로 수정, dataloader 변경 * refactor: github 예외 처리 로직 추가 * refactor: 서브모듈 업데이트 * refactor: test 환경 redirect uri 추가 * refactor: github 예외 테스트 추가 * chore: 서브 모듈 최신화 * feat: OAuth 회원가입, 로그인 변경된 시나리오에 대한 요구사항 반영 - 테스트 실패 문제는 추후 해결합니다. * fix: DataLoader 변경사항 반영 * refactor: NoSuchOAuthMemberException 응답코드 404로 수정 * refactor: 변경된 시나리오에 따른 테스트 반영 * test: GoogleRequester의 예외 상황 테스트 작성 * refactor: OAuth 관련 클래스명, 예외 메세지의 일부 변경 * refactor: ErrorResponseToGetGithubAccessTokenException 네이밍 변경 - ErrorResponseToGetAccessTokenException 으로 변경합니다. * feat: OAuth 회원가입 시 이메일 중복 검사 로직 생성 * refactor: GoogleRequesterTest 관련 잘못된 클래스명, 변수명 수정 * feat: OAuth 회원가입 과정에서 이메일 중복시 OAuthProvider 일치 여부에 따라 상이하게 응답 * refactor: 자동 생성된 Api 문서 레포지토리에서 삭제 * refactor: DuplicateEmailInOAuthFlowException 중복으로 인해 삭제 * refactor: handleOAuthLoginFailHandler 네이밍 변경 handler의 중복 사용으로 인해 변경합니다. * feat: OAuth 회원가입 시도시 이미 가입된 메일인 경우, OAuthProvider를 비교하여 에러 메세지 응답 * feat: DuplicateEmailInOAuthFlowException 생성 회원가입 플로우에서 이미 가입된 이메일로 가입하려 하는 경우 발생시키는 예외입니다. * refactor: GoogleRequesterTest 내의 잘못된 인스턴스명 수정 * refactor: Auth Code를 이용해 이메일을 얻어오는 API 삭제 회원 미가입으로 인한 OAuth 로그인 실패시 필요한 정보를 같이 응답하므로 이제 필요없습니다. * refactor: DuplicateEmailInOAuthFlowException 미사용으로 인해 삭제 * test: NoSuchOAuthMemberException 테스트 커버리지 인상 * refactor: OAuthProviderMismatchException 메세지 수정 Co-authored-by: y_woo :) --- .../authorization/OauthProviderMismatchException.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java index 1d1ab7d02..065fc0bd4 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/authorization/OauthProviderMismatchException.java @@ -7,8 +7,8 @@ public class OauthProviderMismatchException extends ZzimkkongException { private static final String MESSAGE_FORMAT = "소셜 로그인 제공자가 다릅니다. %s를 통해 로그인하세요."; - public OauthProviderMismatchException(final String oauthProvider) { - super(String.format(MESSAGE_FORMAT, oauthProvider), HttpStatus.UNAUTHORIZED); + public OauthProviderMismatchException(final String message) { + super(message, HttpStatus.UNAUTHORIZED); } public static OauthProviderMismatchException from(OauthProvider oauthProvider) { From 310a651345cc5947d54b8602664ee0e531ea54b8 Mon Sep 17 00:00:00 2001 From: Sunny K Date: Fri, 17 Sep 2021 22:18:47 +0900 Subject: [PATCH 25/25] =?UTF-8?q?feat:=20package.json=EC=9D=98=20version?= =?UTF-8?q?=EC=9D=84=201.1.0=EB=A1=9C=20=EC=84=A4=EC=A0=95=20(#564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: version 1.0.2 * version1.1.0 --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index caf364865..3f9fa4b25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "zzimkkong-frontend", - "version": "1.0.2", + "version": "1.1.0", "main": "src/index.tsx", "license": "MIT", "homepage": "https://github.com/woowacourse-teams/2021-zzimkkong",