From cf21b3d1e8ec60ccae25981f4e3495954674df12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=B4=EB=A6=AC=28=EC=B5=9C=ED=98=84=EC=9B=85=29?= Date: Tue, 22 Oct 2024 17:50:58 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20=EC=9D=B8=ED=92=8B=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ios 기기에서 버튼 텍스트 색상이 파란색으로 보였던 문제 해결 * chore: BottomFixedButton 높이 수정 * feat: floating 스타일, 타입 추가 * feat: FloatingInput 구현 - label이 인풋 태그 내부에 위치하도록 relative, absolute 활용 - focus 상태를 관리 * feat: 어떤 입력 Field 인지 나타내는 Title 컴포넌트 생성 - label과는 다른 역할을 한다는 생각을 바탕으로, FieldLabel과 마크업과 스타일이 동일하지만 추가로 하나의 컴포넌트를 추가로 생성 * refactor: FloatingInput을 사용해서 약속을 생성하는 것으로 변경 * chore: DESCRIPTION, ERROR_MESSAGE를 구분 * feat: 스크롤을 막는 컴포넌트 구현 - 모바일 환경에서만 스크롤을 막으면 되기 때문에, 터치 이벤트로 인한 스크롤만 막도록 구현 * refactor: 약속 참여자 로그인 페이지 로직, 스타일 수정 - FloatingInput으로 수정 - useAttendeeLogin 커스텀 훅 활용 - 필드가 페이지 상단에 위치하도록 변경 * refactor: 도메인 정책 변경으로 인한, 1년 뒤의 약속을 생성하려고 하는 경우 토스트 메시지로 알려주는 로직 추가 * design: css 컨벤션을 맞추기 위해 rem으로 수정 * refactor: FloatingLabelInput으로 컴포넌트명 수정, 불필요햔 isFocused 상태 제거 * chore: 컴포넌트명 변경 사항 반영, 버튼 텍스트 상수화 파일명 수정 * chore: 직관적인 함수명을 사용하는 것으로 수정 * chore: IOS 기기에서 드롭다운 텍스트가 파란색으로 보이는 문제 해결 * chore: css 선언 순서 수정 * refactor: 달(Month)를 이동시키는 함수 추상화 * test: 1년 범위를 벗어난 경우 토스트 UI를 활용해서 피드백을 전달하는 책임 테스트 추가 * chore: 테스트 환경에서 절대경로를 인식해야 하는 폴더 추가, svg를 자바스크립트 모듈로 바라볼 수 있도록 설정 추가 * design: 바텀고정버튼 높이 수정 변경 반영 * chore: 불필요한 예외 처리 로직 제거 --- frontend/jest.config.js | 6 + .../FloatingLabelInput.stories.tsx | 24 ++++ .../FloatingLabelInput.styles.ts | 42 ++++++ .../src/components/FloatingInput/index.tsx | 34 +++++ .../MeetingCalendar/Date/Date.styles.ts | 2 +- frontend/src/components/ScrollBlock/index.tsx | 21 +++ .../TimeRangeSelector.styles.ts | 2 +- .../BottomFixedButton.styles.ts | 4 +- .../_common/Dropdown/Dropdown.styles.ts | 5 + .../components/_common/Field/Field.styles.ts | 1 + .../src/components/_common/Field/index.tsx | 14 ++ .../components/_common/Input/Input.styles.ts | 12 +- .../src/components/_common/Input/index.tsx | 5 +- frontend/src/constants/buttons.ts | 5 + frontend/src/constants/inputFields.ts | 29 +++- frontend/src/constants/toasts.ts | 3 + frontend/src/hooks/__test__/Providers.tsx | 8 ++ .../hooks/__test__/renderHookWithProvider.tsx | 11 ++ .../useAttendeeLogin/useAttendeeLogin.ts | 58 ++++++++ .../src/hooks/useCalendar/useCalendar.test.ts | 54 ++++++-- frontend/src/hooks/useCalendar/useCalendar.ts | 29 +++- .../useCreateMeeting/useCreateMeeting.ts | 8 +- .../AttendeeLoginPage.styles.ts | 15 +- .../src/pages/AttendeeLoginPage/index.tsx | 129 +++++++----------- .../components/MeetingDateTime/index.tsx | 11 +- .../components/MeetingHostInfo/index.tsx | 51 ++++--- .../components/MeetingName/index.tsx | 21 +-- .../src/pages/CreateMeetingPage/index.tsx | 2 +- frontend/src/styles/global.ts | 5 + frontend/svgTransformer.js | 5 + 30 files changed, 468 insertions(+), 148 deletions(-) create mode 100644 frontend/src/components/FloatingInput/FloatingLabelInput.stories.tsx create mode 100644 frontend/src/components/FloatingInput/FloatingLabelInput.styles.ts create mode 100644 frontend/src/components/FloatingInput/index.tsx create mode 100644 frontend/src/components/ScrollBlock/index.tsx create mode 100644 frontend/src/constants/buttons.ts create mode 100644 frontend/src/constants/toasts.ts create mode 100644 frontend/src/hooks/__test__/Providers.tsx create mode 100644 frontend/src/hooks/__test__/renderHookWithProvider.tsx create mode 100644 frontend/src/hooks/useAttendeeLogin/useAttendeeLogin.ts create mode 100644 frontend/svgTransformer.js diff --git a/frontend/jest.config.js b/frontend/jest.config.js index e57a1eff5..b1a6964e2 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -3,6 +3,7 @@ module.exports = { testEnvironment: 'jsdom', transform: { '^.+.tsx?$': ['ts-jest', {}], + '^.+\\.svg$': '/svgTransformer.js', }, testEnvironmentOptions: { customExportConditions: [''], @@ -13,5 +14,10 @@ module.exports = { moduleNameMapper: { '^@utils/(.*)$': '/src/utils/$1', '^@constants/(.*)$': '/src/constants/$1', + '^@contexts/(.*)$': '/src/contexts/$1', + '^@components/(.*)$': '/src/components/$1', + '^@hooks/(.*)$': '/src/hooks/$1', + '^@assets/(.*)$': '/src/assets/$1', + '^@styles/(.*)$': '/src/styles/$1', }, }; diff --git a/frontend/src/components/FloatingInput/FloatingLabelInput.stories.tsx b/frontend/src/components/FloatingInput/FloatingLabelInput.stories.tsx new file mode 100644 index 000000000..802c75d7d --- /dev/null +++ b/frontend/src/components/FloatingInput/FloatingLabelInput.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import FloatingLabelInput from '.'; + +const meta = { + title: 'Components/Inputs/FloatingLabelInput', + component: FloatingLabelInput, + argTypes: { + label: { control: 'text' }, + isError: { control: 'boolean' }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: '낙타해리빙봉', + placeholder: '송재석최현웅김윤경', + isError: false, + }, +}; diff --git a/frontend/src/components/FloatingInput/FloatingLabelInput.styles.ts b/frontend/src/components/FloatingInput/FloatingLabelInput.styles.ts new file mode 100644 index 000000000..5ec720e9f --- /dev/null +++ b/frontend/src/components/FloatingInput/FloatingLabelInput.styles.ts @@ -0,0 +1,42 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_floatingLabelContainer = (isError: boolean) => css` + position: relative; + display: inline-block; + width: 100%; + color: ${isError ? '#EB1E1E' : '#71717a'}; + + &:focus-within label { + color: ${isError ? '#EB1E1E' : theme.colors.pink.medium}; + } +`; + +export const s_floatingLabelInput = (isError: boolean) => css` + appearance: none; + box-shadow: ${isError ? `0 0 0 0.1rem #EB1E1E` : `0 0 0 0.1rem #71717a`}; + transition: box-shadow 0.3s; + + &::placeholder { + color: #71717a; + } + + &:focus { + box-shadow: ${isError + ? `0 0 0 0.1rem #EB1E1E` + : `0 0 0 0.1rem ${theme.colors.pink.mediumLight}`}; + } +`; + +export const s_floatingLabel = () => css` + position: absolute; + top: 0.4rem; + left: 1em; + + ${theme.typography.captionMedium}; + + background: transparent; + + transition: color 0.3s; +`; diff --git a/frontend/src/components/FloatingInput/index.tsx b/frontend/src/components/FloatingInput/index.tsx new file mode 100644 index 000000000..b7748fbcd --- /dev/null +++ b/frontend/src/components/FloatingInput/index.tsx @@ -0,0 +1,34 @@ +import type { InputProps } from '@components/_common/Input'; +import Input from '@components/_common/Input'; + +import { + s_floatingLabel, + s_floatingLabelContainer, + s_floatingLabelInput, +} from './FloatingLabelInput.styles'; + +interface FloatingLabelInputProps extends InputProps { + label: string; + isError: boolean; +} +export default function FloatingLabelInput({ + label, + placeholder, + isError, + ...props +}: FloatingLabelInputProps) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Date/Date.styles.ts b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts index 4499c3b6b..4a704d6de 100644 --- a/frontend/src/components/MeetingCalendar/Date/Date.styles.ts +++ b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts @@ -121,7 +121,7 @@ export const s_rangeStart = (isAllRangeSelected: boolean) => css` position: absolute; top: 0; - right: 0.4px; + right: 0.02rem; bottom: 0; width: 20%; diff --git a/frontend/src/components/ScrollBlock/index.tsx b/frontend/src/components/ScrollBlock/index.tsx new file mode 100644 index 000000000..2f670dae2 --- /dev/null +++ b/frontend/src/components/ScrollBlock/index.tsx @@ -0,0 +1,21 @@ +import type { PropsWithChildren } from 'react'; +import { useEffect, useRef } from 'react'; + +export default function ScrollBlock({ children }: PropsWithChildren) { + const contentRef = useRef(null); + + useEffect(() => { + const preventTouchMove = (e: TouchEvent) => { + e.preventDefault(); + }; + + // 터치 이벤트를 사용해서 스크롤을 할 경우, 해당 스크롤을 막는다는 것을 브라우저에게 명시적으로 알려주기 위해서 passive 속성 추가(@해리) + document.addEventListener('touchmove', preventTouchMove, { passive: false }); + + return () => { + document.removeEventListener('touchmove', preventTouchMove); + }; + }, []); + + return
{children}
; +} diff --git a/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts b/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts index 327128d45..65973e655 100644 --- a/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts +++ b/frontend/src/components/TimeRangeSelector/TimeRangeSelector.styles.ts @@ -3,6 +3,6 @@ import { css } from '@emotion/react'; export const s_dropdownContainer = css` display: flex; gap: 1.2rem; - justify-content: flex-start; align-items: center; + justify-content: flex-start; `; diff --git a/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts index 319ff735f..664282be4 100644 --- a/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts +++ b/frontend/src/components/_common/Buttons/BottomFixedButton/BottomFixedButton.styles.ts @@ -6,8 +6,10 @@ export const s_bottomFixedStyles = css` /* 버튼 컴포넌트의 full variants를 사용하려고 했으나 6rem보다 height값이 작아 직접 높이를 정의했어요(@해리) full 버튼에 이미 의존하고 있는 컴포넌트들이 많아서 높이를 full 스타일을 변경할 수는 없었습니다. + + 버튼의 높이가 너무 높다는 피드백을 반영하기 위해서 높이 수정 5.2rem(@해리) */ - height: 6rem; + height: 5.2rem; box-shadow: 0 -4px 4px rgb(0 0 0 / 25%); `; diff --git a/frontend/src/components/_common/Dropdown/Dropdown.styles.ts b/frontend/src/components/_common/Dropdown/Dropdown.styles.ts index 407608dbb..86630a8f5 100644 --- a/frontend/src/components/_common/Dropdown/Dropdown.styles.ts +++ b/frontend/src/components/_common/Dropdown/Dropdown.styles.ts @@ -1,8 +1,13 @@ import { css } from '@emotion/react'; +import theme from '@styles/theme'; + export const s_dropdown = css` width: fit-content; height: 2.8rem; padding: 0.4rem; + + color: ${theme.colors.black}; + border-radius: 0.4rem; `; diff --git a/frontend/src/components/_common/Field/Field.styles.ts b/frontend/src/components/_common/Field/Field.styles.ts index 389b86872..b2abed5a3 100644 --- a/frontend/src/components/_common/Field/Field.styles.ts +++ b/frontend/src/components/_common/Field/Field.styles.ts @@ -7,6 +7,7 @@ export const s_field = css` display: flex; flex-direction: column; gap: 0.8rem; + margin-bottom: 1.2rem; `; export const s_label = css` diff --git a/frontend/src/components/_common/Field/index.tsx b/frontend/src/components/_common/Field/index.tsx index 74fc3a0fa..9708ce096 100644 --- a/frontend/src/components/_common/Field/index.tsx +++ b/frontend/src/components/_common/Field/index.tsx @@ -1,5 +1,8 @@ import type { ReactNode } from 'react'; +import FloatingLabelInput from '@components/FloatingInput'; + +import Text from '../Text'; import { s_description, s_errorMessage, @@ -16,6 +19,14 @@ const Field = ({ children }: FieldProps) => { return
{children}
; }; +interface FieldTitleProps { + title: string; +} + +const FieldTitle = ({ title }: FieldTitleProps) => { + return {title}; +}; + interface FieldLabelProps { id: string; labelText: string; @@ -29,6 +40,7 @@ const FieldLabel = ({ id, labelText }: FieldLabelProps) => ( interface FieldDescriptionProps { description?: string; + accentText?: string; } const FieldDescription = ({ description }: FieldDescriptionProps) => @@ -44,8 +56,10 @@ const FieldErrorMessage = ({ errorMessage }: FieldErrorMessageProps) => ( ); +Field.Title = FieldTitle; Field.Label = FieldLabel; Field.Description = FieldDescription; Field.ErrorMessage = FieldErrorMessage; +Field.FloatingLabelInput = FloatingLabelInput; export default Field; diff --git a/frontend/src/components/_common/Input/Input.styles.ts b/frontend/src/components/_common/Input/Input.styles.ts index 884d2ffde..a6b68df3d 100644 --- a/frontend/src/components/_common/Input/Input.styles.ts +++ b/frontend/src/components/_common/Input/Input.styles.ts @@ -4,9 +4,8 @@ import theme from '@styles/theme'; const baseInputStyle = css` width: 100%; - height: 4.4rem; - padding: 0.8rem; - outline-color: ${theme.colors.primary}; + height: 4.8rem; + outline: none; ${theme.typography.bodyMedium} `; @@ -19,4 +18,11 @@ export const s_input = { border: none; outline: none; `, + floating: css` + padding-top: 1.6rem; /* 텍스트를 아래로 내리기 위해 top padding을 더 줌 (@해리) */ + padding-left: 1.2rem; + border: none; + ${baseInputStyle} + border-radius: 1.2rem; + `, }; diff --git a/frontend/src/components/_common/Input/index.tsx b/frontend/src/components/_common/Input/index.tsx index d39e79419..865437d30 100644 --- a/frontend/src/components/_common/Input/index.tsx +++ b/frontend/src/components/_common/Input/index.tsx @@ -2,12 +2,13 @@ import type { InputHTMLAttributes } from 'react'; import { s_input } from './Input.styles'; -export type InputVariant = 'default' | 'transparent'; +export type InputVariant = 'default' | 'transparent' | 'floating'; export interface InputProps extends InputHTMLAttributes { variant?: InputVariant; + isError?: boolean; } export default function Input({ variant = 'default', type = 'text', ...props }: InputProps) { - return ; + return ; } diff --git a/frontend/src/constants/buttons.ts b/frontend/src/constants/buttons.ts new file mode 100644 index 000000000..9fcd9f919 --- /dev/null +++ b/frontend/src/constants/buttons.ts @@ -0,0 +1,5 @@ +export const MEETING_BUTTON_TEXTS = { + create: '약속 생성하기', + next: '다음', + register: '등록하러 가기', +}; diff --git a/frontend/src/constants/inputFields.ts b/frontend/src/constants/inputFields.ts index 63c563616..8e1c37bbf 100644 --- a/frontend/src/constants/inputFields.ts +++ b/frontend/src/constants/inputFields.ts @@ -9,8 +9,35 @@ export const INPUT_RULES = { }; export const FIELD_DESCRIPTIONS = { + meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.', + nickname: '1~5자 사이로 입력해 주세요.', + password: '4자리 숫자로 입력해 주세요.', + date: '날짜를 하나씩 클릭해 여러 날짜를 선택하거나\n시작일과 종료일을 클릭해 사이의 모든 날짜를 선택해 보세요', +}; + +export const FIELD_TITLES = { + meetingName: '약속 정보 입력', + meetingHostInfo: '약속 주최자 정보 입력', + meetingDateTime: '약속 후보 날짜 선택', + meetingTimeRange: '약속 시간 범위 선택', + attendeeLogin: '내 정보 입력', +}; + +export const FIELD_LABELS = { + meetingName: '약속 이름', + nickname: '닉네임', + password: '비밀번호', + onlyDate: '날짜만 선택할래요', +}; + +export const FIELD_PLACEHOLDERS = { + meetingName: '10자 이내의 약속 이름 입력', + nickname: '5자 이내의 약속 닉네임 입력', + password: '4자리 숫자 입력', +}; + +export const FIELD_ERROR_MESSAGES = { meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.', nickname: '닉네임은 1~5자 사이로 입력해 주세요.', password: '비밀번호는 4자리 숫자로 입력해 주세요.', - date: '날짜를 하나씩 클릭해 여러 날짜를 선택하거나\n시작일과 종료일을 클릭해 사이의 모든 날짜를 선택해 보세요', }; diff --git a/frontend/src/constants/toasts.ts b/frontend/src/constants/toasts.ts new file mode 100644 index 000000000..af28336f5 --- /dev/null +++ b/frontend/src/constants/toasts.ts @@ -0,0 +1,3 @@ +export const TOAST_MESSAGES = { + OUT_OF_ONE_YEAR_RANGE: '최대 1년뒤의 약속만 생성할 수 있어요', +}; diff --git a/frontend/src/hooks/__test__/Providers.tsx b/frontend/src/hooks/__test__/Providers.tsx new file mode 100644 index 000000000..47b4fd510 --- /dev/null +++ b/frontend/src/hooks/__test__/Providers.tsx @@ -0,0 +1,8 @@ +import type { PropsWithChildren } from 'react'; + +import ToastProvider from '@contexts/ToastProvider'; + +// 필요한 _Provider 들은 유동적으로 추가해서 테스트 환경에서 사용할 수 있어요(@해리) +export default function Providers({ children }: PropsWithChildren) { + return {children}; +} diff --git a/frontend/src/hooks/__test__/renderHookWithProvider.tsx b/frontend/src/hooks/__test__/renderHookWithProvider.tsx new file mode 100644 index 000000000..ec2478da0 --- /dev/null +++ b/frontend/src/hooks/__test__/renderHookWithProvider.tsx @@ -0,0 +1,11 @@ +import type { RenderOptions } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; + +import Providers from './Providers'; + +export default function render(callback: () => T, options?: Omit) { + return renderHook(callback, { + wrapper: Providers, + ...options, + }); +} diff --git a/frontend/src/hooks/useAttendeeLogin/useAttendeeLogin.ts b/frontend/src/hooks/useAttendeeLogin/useAttendeeLogin.ts new file mode 100644 index 000000000..1a92a7101 --- /dev/null +++ b/frontend/src/hooks/useAttendeeLogin/useAttendeeLogin.ts @@ -0,0 +1,58 @@ +import { useContext } from 'react'; + +import { UuidContext } from '@contexts/UuidProvider'; + +import useInput from '@hooks/useInput/useInput'; + +import { usePostLoginMutation } from '@stores/servers/user/mutations'; + +import { FIELD_ERROR_MESSAGES, INPUT_FIELD_PATTERN } from '@constants/inputFields'; + +const useAttendeeLogin = () => { + const { uuid } = useContext(UuidContext); + const { mutate: postLoginMutate } = usePostLoginMutation(); + + const attendeeNameInput = useInput({ + pattern: INPUT_FIELD_PATTERN.nickname, + errorMessage: FIELD_ERROR_MESSAGES.nickname, + }); + const isAttendeeNameError = attendeeNameInput.errorMessage !== null; + const attendeeNameField = { ...attendeeNameInput, isError: isAttendeeNameError }; + + const attendeePasswordInput = useInput({ + pattern: INPUT_FIELD_PATTERN.password, + errorMessage: FIELD_ERROR_MESSAGES.password, + }); + const isAttendeePasswordError = attendeePasswordInput.errorMessage !== null; + const attendeePasswordField = { ...attendeePasswordInput, isError: isAttendeePasswordError }; + + const isFormValid = () => { + const hasLoginFormError = isAttendeeNameError || isAttendeePasswordError; + + if (hasLoginFormError) { + return false; + } + + const requiredFields = [attendeeNameInput.value, attendeePasswordInput.value]; + const isAllFieldsFilled = requiredFields.every((field) => field !== ''); + + return isAllFieldsFilled; + }; + + const handleLoginButtonClick = async () => { + postLoginMutate({ + uuid, + request: { attendeeName: attendeeNameInput.value, password: attendeePasswordInput.value }, + }); + }; + + return { + attendeeNameField, + attendeePasswordField, + isFormValid, + handleLoginButtonClick, + uuid, + }; +}; + +export default useAttendeeLogin; diff --git a/frontend/src/hooks/useCalendar/useCalendar.test.ts b/frontend/src/hooks/useCalendar/useCalendar.test.ts index 1935c6437..33f616e1a 100644 --- a/frontend/src/hooks/useCalendar/useCalendar.test.ts +++ b/frontend/src/hooks/useCalendar/useCalendar.test.ts @@ -1,10 +1,22 @@ -import { renderHook } from '@testing-library/react'; import { act } from 'react'; +import renderHookWithProvider from '@hooks/__test__/renderHookWithProvider'; + import { getFullDate } from '@utils/date'; +import { TOAST_MESSAGES } from '@constants/toasts'; + import useCalendar from './useCalendar'; +// 테스트 환경에서 useToast를 호출하면 mockAddToast 함수를 호출합니다.(@해리) +const mockAddToast = jest.fn(); +jest.mock('@hooks/useToast/useToast', () => ({ + __esModule: true, + default: () => ({ + addToast: mockAddToast, + }), +})); + describe('useCalendar', () => { const TEST_YEAR = 2024; const TEST_MONTH = 9; @@ -24,7 +36,7 @@ describe('useCalendar', () => { it('현재 년도, 월을 올바르게 계산해서 반환한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { headers, isCurrentMonth } = result.current; const { currentYear, currentMonth } = headers; @@ -39,7 +51,7 @@ describe('useCalendar', () => { it('12월에서 다음 달로 이동하면 다음 년도로 변경되어야 한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, LAST_MONTH_INDEX, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToNextMonth } = view; @@ -58,7 +70,7 @@ describe('useCalendar', () => { it('1월에서 이전 년도로 이동하면 이전 년도로 변경되어야 한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, FIRST_MONTH_INDEX, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToPrevMonth } = view; @@ -79,7 +91,7 @@ describe('useCalendar', () => { it('이전 달로 이동하면, 달 데이터가 변경되어야 한다.', () => { jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToPrevMonth } = view; @@ -98,7 +110,7 @@ describe('useCalendar', () => { it('다음 달로 이동하면 달 데이터가 변경되어야 한다..', () => { jest.setSystemTime(new Date(TEST_YEAR, TEST_MONTH, TEST_DATE)); - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { view } = result.current; const { moveToNextMonth } = view; @@ -117,7 +129,7 @@ describe('useCalendar', () => { describe('월 이동 시, 변경된 달력 데이터 계산', () => { it('현재 달의 마지막 주에 있는 current 상태의 날짜들이 다음 달로 이동했을 때 prev 상태로 변경되어야 한다.', () => { - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { body: { value: initialCalendarData }, view: { moveToNextMonth }, @@ -145,7 +157,7 @@ describe('useCalendar', () => { }); it('현재 달의 첫 주에 있는 current 상태의 날짜들이 이전 달로 이동했을 때 next 상태로 변경되어야 한다.', () => { - const { result } = renderHook(() => useCalendar()); + const { result } = renderHookWithProvider(useCalendar); const { body: { value: initialCalendarData }, view: { moveToPrevMonth }, @@ -172,4 +184,30 @@ describe('useCalendar', () => { }); }); }); + + describe('1년 범위 이동 예외처리', () => { + const TEST_YEAR = 2024; + const TEST_MONTH = 9; // 10월 + const TEST_DATE = 4; + + beforeEach(() => { + jest.setSystemTime(new Date(TEST_YEAR + 1, TEST_MONTH, TEST_DATE)); + }); + + it('현재 월 기준, 약속 날짜 범위가 1년을 벗어나면 토스트 UI를 활용하여 사용자에게 예외 피드백을 전달한다', () => { + const { result } = renderHookWithProvider(useCalendar); + + const { view } = result.current; + const { moveToNextMonth } = view; + act(() => { + moveToNextMonth(); + }); + + expect(mockAddToast).toHaveBeenCalledWith({ + message: TOAST_MESSAGES.OUT_OF_ONE_YEAR_RANGE, + type: 'warning', + duration: 2000, + }); + }); + }); }); diff --git a/frontend/src/hooks/useCalendar/useCalendar.ts b/frontend/src/hooks/useCalendar/useCalendar.ts index 17448f554..62c4d876e 100644 --- a/frontend/src/hooks/useCalendar/useCalendar.ts +++ b/frontend/src/hooks/useCalendar/useCalendar.ts @@ -1,7 +1,11 @@ import { useState } from 'react'; import type { MonthlyDays } from 'types/calendar'; -import { getMonth, getYear } from '@utils/date'; +import useToast from '@hooks/useToast/useToast'; + +import { getFullDate, getMonth, getYear } from '@utils/date'; + +import { TOAST_MESSAGES } from '@constants/toasts'; import { getMonthlyCalendarDate } from './useCalendar.utils'; @@ -23,20 +27,39 @@ interface useCalendarReturn { } const TODAY = new Date(); +const ONE_YEAR_LATER = getFullDate(new Date(getYear(TODAY) + 1, getMonth(TODAY))); + +type MonthDelta = -1 | 1; const useCalendar = (): useCalendarReturn => { const [currentFullDate, setCurrentFullDate] = useState(new Date()); + const { addToast } = useToast(); const currentYear = getYear(currentFullDate); const currentMonth = getMonth(currentFullDate); const isCurrentMonth = getYear(TODAY) === currentYear && getMonth(TODAY) === currentMonth; + const moveMonth = (monthDelta: MonthDelta) => { + setCurrentFullDate(new Date(currentYear, currentMonth + monthDelta)); + }; + const moveToPrevMonth = () => { - setCurrentFullDate(new Date(currentYear, currentMonth - 1)); + moveMonth(-1); }; const moveToNextMonth = () => { - setCurrentFullDate(new Date(currentYear, currentMonth + 1)); + const fullDate = getFullDate(currentFullDate); + + if (fullDate >= ONE_YEAR_LATER) { + addToast({ + message: TOAST_MESSAGES.OUT_OF_ONE_YEAR_RANGE, + type: 'warning', + duration: 2000, + }); + return; + } + + moveMonth(1); }; const monthlyCalendarDate = getMonthlyCalendarDate(currentFullDate); diff --git a/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts index 7ffdc65fa..6a34fb913 100644 --- a/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts +++ b/frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts @@ -6,7 +6,7 @@ import useTimeRangeDropdown from '@hooks/useTimeRangeDropdown/useTimeRangeDropdo import { usePostMeetingMutation } from '@stores/servers/meeting/mutations'; -import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN, INPUT_RULES } from '@constants/inputFields'; +import { FIELD_ERROR_MESSAGES, INPUT_FIELD_PATTERN, INPUT_RULES } from '@constants/inputFields'; const checkInputInvalid = (value: string, errorMessage: string | null) => value.length < INPUT_RULES.minimumLength || errorMessage !== null; @@ -14,7 +14,7 @@ const checkInputInvalid = (value: string, errorMessage: string | null) => const useCreateMeeting = () => { const meetingNameInput = useInput({ pattern: INPUT_FIELD_PATTERN.meetingName, - errorMessage: FIELD_DESCRIPTIONS.meetingName, + errorMessage: FIELD_ERROR_MESSAGES.meetingName, }); const isMeetingNameInvalid = checkInputInvalid( meetingNameInput.value, @@ -23,11 +23,11 @@ const useCreateMeeting = () => { const hostNickNameInput = useInput({ pattern: INPUT_FIELD_PATTERN.nickname, - errorMessage: FIELD_DESCRIPTIONS.nickname, + errorMessage: FIELD_ERROR_MESSAGES.nickname, }); const hostPasswordInput = useInput({ pattern: INPUT_FIELD_PATTERN.password, - errorMessage: FIELD_DESCRIPTIONS.password, + errorMessage: FIELD_ERROR_MESSAGES.password, }); const isHostInfoInvalid = checkInputInvalid(hostNickNameInput.value, hostNickNameInput.errorMessage) || diff --git a/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts b/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts index e86bad389..1c60d902a 100644 --- a/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts +++ b/frontend/src/pages/AttendeeLoginPage/AttendeeLoginPage.styles.ts @@ -3,22 +3,13 @@ import { css } from '@emotion/react'; export const s_container = css` display: flex; flex-direction: column; - gap: 2rem; - align-items: center; - justify-content: center; - - height: calc(100vh - 8.4rem); - padding: 0 2rem; + row-gap: 0.4rem; + height: 100%; `; export const s_inputContainer = css` display: flex; flex-direction: column; - gap: 1rem; - + justify-content: center; width: 100%; - padding: 1.6rem; - - background-color: #f7dacb; - border-radius: 0.5rem; `; diff --git a/frontend/src/pages/AttendeeLoginPage/index.tsx b/frontend/src/pages/AttendeeLoginPage/index.tsx index def8a836a..e70778b05 100644 --- a/frontend/src/pages/AttendeeLoginPage/index.tsx +++ b/frontend/src/pages/AttendeeLoginPage/index.tsx @@ -1,66 +1,36 @@ -import { useContext } from 'react'; - import ContentLayout from '@layouts/ContentLayout'; -import { UuidContext } from '@contexts/UuidProvider'; - +import ScrollBlock from '@components/ScrollBlock'; import BackButton from '@components/_common/Buttons/BackButton'; import { Button } from '@components/_common/Buttons/Button'; import Field from '@components/_common/Field'; import Header from '@components/_common/Header'; -import Input from '@components/_common/Input'; - -import useInput from '@hooks/useInput/useInput'; +import Text from '@components/_common/Text'; -import { usePostLoginMutation } from '@stores/servers/user/mutations'; +import useAttendeeLogin from '@hooks/useAttendeeLogin/useAttendeeLogin'; -import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN } from '@constants/inputFields'; +import { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_LABELS, FIELD_PLACEHOLDERS, FIELD_TITLES } from '@constants/inputFields'; import { s_container, s_inputContainer } from './AttendeeLoginPage.styles'; export default function AttendeeLoginPage() { - const { uuid } = useContext(UuidContext); - - const { mutate: postLoginMutate } = usePostLoginMutation(); + const { attendeeNameField, attendeePasswordField, handleLoginButtonClick, isFormValid, uuid } = + useAttendeeLogin(); const { value: attendeeName, onValueChange: handleAttendeeNameChange, errorMessage: attendeeNameErrorMessage, - } = useInput({ - pattern: INPUT_FIELD_PATTERN.nickname, - errorMessage: FIELD_DESCRIPTIONS.nickname, - }); + isError: isAttendeeNameError, + } = attendeeNameField; const { value: attendeePassword, onValueChange: handleAttendeePasswordChange, errorMessage: attendeePasswordErrorMessage, - } = useInput({ - pattern: INPUT_FIELD_PATTERN.password, - errorMessage: FIELD_DESCRIPTIONS.password, - }); - - const isFormValid = () => { - const errorMessages = [attendeeNameErrorMessage, attendeePasswordErrorMessage]; - const hasErrors = errorMessages.some((errorMessage) => errorMessage !== null); - - if (hasErrors) { - return false; - } - - const requiredFields = [attendeeName, attendeePassword]; - const isAllFieldsFilled = requiredFields.every((field) => field !== ''); - - return isAllFieldsFilled; - }; - - const handleLoginButtonClick = async () => { - postLoginMutate({ - uuid, - request: { attendeeName, password: attendeePassword }, - }); - }; + isError: isAttendeePasswordError, + } = attendeePasswordField; return ( <> @@ -69,43 +39,48 @@ export default function AttendeeLoginPage() { -
-
- - - - - - - - - - - - - + +
+
+ + + + + 약속에서 사용할 를 입력해 주세요 + + + + + +
+
- -
+ ); diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx index 9983c6a59..c01458866 100644 --- a/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingDateTime/index.tsx @@ -14,7 +14,8 @@ import type useDateSelect from '@hooks/useDateSelect/useDateSelect'; import type useMeetingType from '@hooks/useMeetingType/useMeetingType'; import type { UseTimeRangeDropdownReturn } from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown'; -import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; +import { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_DESCRIPTIONS, FIELD_LABELS, FIELD_TITLES } from '@constants/inputFields'; import { s_container, s_dateCandidateSelector } from './MeetingDateTime.styles'; @@ -74,7 +75,7 @@ export default function MeetingDateTime({
- +
{!isChecked && ( - + - 약속 생성하기 + {MEETING_BUTTON_TEXTS.create}
); diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx index 77493d6c7..a74cff53a 100644 --- a/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingHostInfo/index.tsx @@ -1,11 +1,13 @@ +import ScrollBlock from '@components/ScrollBlock'; import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; import Field from '@components/_common/Field'; -import Input from '@components/_common/Input'; +import Text from '@components/_common/Text'; import useButtonOnKeyboard from '@hooks/useButtonOnKeyboard/useButtonOnKeyboard'; import type { UseInputReturn } from '@hooks/useInput/useInput'; -import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; +import { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_LABELS, FIELD_PLACEHOLDERS, FIELD_TITLES } from '@constants/inputFields'; interface MeetingHostInfoProps { hostNickNameInput: UseInputReturn; @@ -32,36 +34,45 @@ export default function MeetingHostInfo({ } = hostPasswordInput; const resizedButtonHeight = useButtonOnKeyboard(); + const isHostNickNameError = hostNickNameErrorMessage !== null; + const isHostPasswordError = hostPasswordErrorMessage !== null; return ( - <> + - - - - - - - - - - + + 약속을 생성하면 + + 돼요 + + + - + - 다음 + {MEETING_BUTTON_TEXTS.next} - + ); } diff --git a/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx b/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx index 98ec588b2..927ca277e 100644 --- a/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/components/MeetingName/index.tsx @@ -1,11 +1,12 @@ +import ScrollBlock from '@components/ScrollBlock'; import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton'; import Field from '@components/_common/Field'; -import Input from '@components/_common/Input'; import useButtonOnKeyboard from '@hooks/useButtonOnKeyboard/useButtonOnKeyboard'; import type { UseInputReturn } from '@hooks/useInput/useInput'; -import { FIELD_DESCRIPTIONS } from '@constants/inputFields'; +import { MEETING_BUTTON_TEXTS } from '@constants/buttons'; +import { FIELD_LABELS, FIELD_PLACEHOLDERS, FIELD_TITLES } from '@constants/inputFields'; interface MeetingNameProps { meetingNameInput: UseInputReturn; @@ -24,18 +25,20 @@ export default function MeetingName({ errorMessage: meetingNameErrorMessage, } = meetingNameInput; + const isTextError = meetingNameErrorMessage !== null; const resizedButtonHeight = useButtonOnKeyboard(); return ( - <> + - - - + @@ -45,8 +48,8 @@ export default function MeetingName({ disabled={isMeetingNameInvalid} height={resizedButtonHeight} > - 다음 + {MEETING_BUTTON_TEXTS.next} - + ); } diff --git a/frontend/src/pages/CreateMeetingPage/index.tsx b/frontend/src/pages/CreateMeetingPage/index.tsx index 6d43f361f..f31f4d86d 100644 --- a/frontend/src/pages/CreateMeetingPage/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/index.tsx @@ -67,7 +67,7 @@ export default function CreateMeetingPage() { {/* BottomFixedButton이 요소를 가리는 현상이 있어 버튼 높이(6rem)와 같은 크기의 div 요소 배치 */} -
+