Skip to content

Commit

Permalink
[FE] 약속 생성 흐름에 퍼널 패턴 적용 (#356)
Browse files Browse the repository at this point in the history
* chore: 퍼널 패턴에서 사용할 타입 정의

- readonly 타입의 문자열 스텝들을 StepType으로 정의
- FunnelProps, StepProps의 타입을 StepType에 포함되는 Steps 타입을 사용해서 정의

* feat: 현재 렌더링 해야 할 자식 컴포넌트를 결정하는 FunnelMain 컴포넌트 구현

- Funnel 컴포넌트의 여러 자식 컴포넌트 중 child 컴포넌트가 스텝에 속하는 컴포넌트인지 확인
- 현재 스텝에 맞는 targetStep 컴포넌트를 반환

* feat: 복잡한 UI 흐름을 관리하는 useFunnel 커스텀 훅 구현

- react-router-dom 라이브러리가 제공하는 useLocation, useNavigate 훅을 활용해서 라우팅 기능 구현
- 새로고침해도 현재 스텝은 유지
- Step 컴포넌트는 Funnel의 여러 자식 컴포넌트들로 현재 스텝에 맞는 컴포넌트만 렌더링

* chore: useInput 커스텀 훅의 반환값 타입 정의

- useInput 커스텀 훅의 반환값을 props로 받는 컴포넌트 정의에서 활용할 수 있도록 useInput 커스텀 훅의 반환값 타입을 정의

* chore: useTimeRangeDropdown 커스텀 훅의 반환값 타입 정의

- useTimeRangeDropdown 커스텀 훅의 반환값을 props로 받는 컴포넌트 정의에서 활용할 수 있도록 useTimeRangeDropdown 커스텀 훅의 반환값 타입을 정의

* feat: 약속을 생성할 때 필요한 모든 지역 상태를 관리하는 useCreateMeeting 커스텀 훅 구현

* feat: 약속 이름을 입력받는 컴포넌트 구현

* feat: 약속 주최자 정보(닉네임, 비밀번호)를 입력받는 컴포넌트 구현

* feat: 약속 날짜, 시간 범위를 입력받는 컴포넌트 구현

* feat: 뷰포트 하단에 고정되는 바텀 버튼 컴포넌트 구현

* feat: 모바일 키보드 위로 바텀에 고정된 버튼이 올라올 수 있도록 하는 커스텀 훅 구현

* feat: 약속 생성 UI 흐름을 3단계로 구분하는 약속 생성 페이지 구현

* design: 모바일 화면에서 스크롤이 되는 문제를 해결하기 위해서 height 속성값 수정

* chore: 사용하지 않는 html 태그 제거

* chore: 바텀 고정 버튼 초기 높이 상수화

* chore: RouteFunnel 컴포넌트 오타 수정

* chore: useInput 커스텀 훅의 반환 타입을 ReturnType 유틸리티 타입을 활용하는 것으로 수정

* chore: useTimeRangeDropdown 커스텀 훅의 반환 타입을 ReturnType 유틸리티 타입을 활용하는 것으로 수정

* refactor: 약속 생성 api 함수 추가, 입력 필드 유효성 검증 함수 추가 분리, invalid를 하나의 단어로 사용하도록 수정

* chore: 입력 최소길이 상수 추가

* chore: invalid를 하나의 단어로 사용해서 props 정의, api 요청 함수 추가

* chore: 약속 생성 스텝 네이밍 상수화

* chore: invalid로 props 네이밍 수정

* chore: invalid로 props 네이밍 수정, 약속 생성 api 핸들러 추가

* chore: 사용하지 않는 hidden css 속성 제거

* refactor: 선택된 날짜 상태 관리를 set 자료구조를 사용하는 것으로 수정

* chore: Invalid 단어 오타 수정
  • Loading branch information
hwinkr authored Oct 7, 2024
1 parent 71c6779 commit fdf752f
Show file tree
Hide file tree
Showing 16 changed files with 509 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { css } from '@emotion/react';

export const s_bottomFixedStyles = css`
width: calc(100% + 1.6rem * 2);
max-width: 43rem;
/* 버튼 컴포넌트의 full variants를 사용하려고 했으나 6rem보다 height값이 작아 직접 높이를 정의했어요(@해리)
full 버튼에 이미 의존하고 있는 컴포넌트들이 많아서 높이를 full 스타일을 변경할 수는 없었습니다.
*/
height: 6rem;
box-shadow: 0 -4px 4px rgb(0 0 0 / 25%);
`;

export const s_bottomFixedButtonContainer = (height = 0) => css`
position: fixed;
bottom: 0;
left: 0;
transform: translateY(-${height}px);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 6rem;
background-color: transparent;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ButtonHTMLAttributes } from 'react';
import React from 'react';

import { Button } from '../Button';
import { s_bottomFixedButtonContainer, s_bottomFixedStyles } from './BottomFixedButton.styles';

interface BottomFixedButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
height?: number;
isLoading?: boolean;
}

export default function BottomFixedButton({
children,
height = 0,
isLoading = false,
...props
}: BottomFixedButtonProps) {
return (
<div css={s_bottomFixedButtonContainer(height)}>
<Button
variant="primary"
size="full"
isLoading={isLoading}
borderRadius={0}
customCss={s_bottomFixedStyles}
{...props}
>
{children}
</Button>
</div>
);
}
4 changes: 4 additions & 0 deletions frontend/src/constants/inputFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export const INPUT_FIELD_PATTERN = {
password: /^\d{4}$/, // 4자리 숫자
};

export const INPUT_RULES = {
minimumLength: 1,
};

export const FIELD_DESCRIPTIONS = {
meetingName: '약속 이름은 1~10자 사이로 입력해 주세요.',
nickname: '닉네임은 1~5자 사이로 입력해 주세요.',
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/constants/meeting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const CREATE_MEETING_STEPS = {
meetingName: '약속이름',
meetingHostInfo: '약속주최자정보',
meetingDateTime: '약속날짜시간정보',
} as const;

export const meetingStepValues = Object.values(CREATE_MEETING_STEPS);
27 changes: 27 additions & 0 deletions frontend/src/hooks/useButtonOnKeyboard/useButtonOnKeyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';

const INITIAL_BUTTON_HEIGHT = 0;

const useButtonOnKeyboard = () => {
const [resizedButtonHeight, setResizedButtonHeight] = useState(INITIAL_BUTTON_HEIGHT);

useEffect(() => {
const handleButtonHeightResize = () => {
if (!visualViewport?.height) return;

setResizedButtonHeight(window.innerHeight - visualViewport.height);
};

// 약속 이름 -> 약속 주최자 정보 입력으로 넘어갈 때 다음 버튼을 모바일 키보드로 올리기 위해서 resize 이벤트가 발생하지 않더라도 초기에 실행되도록 구현했어요.(@해리)
handleButtonHeightResize();
visualViewport?.addEventListener('resize', handleButtonHeightResize);

return () => {
visualViewport?.removeEventListener('resize', handleButtonHeightResize);
};
}, []);

return resizedButtonHeight;
};

export default useButtonOnKeyboard;
87 changes: 87 additions & 0 deletions frontend/src/hooks/useCreateMeeting/useCreateMeeting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useState } from 'react';

import useInput from '@hooks/useInput/useInput';
import { INITIAL_END_TIME, INITIAL_START_TIME } from '@hooks/useTimeRangeDropdown/constants';
import useTimeRangeDropdown from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown';

import { usePostMeetingMutation } from '@stores/servers/meeting/mutation';

import { FIELD_DESCRIPTIONS, INPUT_FIELD_PATTERN, INPUT_RULES } from '@constants/inputFields';

const checkInputInvalid = (value: string, errorMessage: string | null) =>
value.length < INPUT_RULES.minimumLength || errorMessage !== null;

const useCreateMeeting = () => {
const meetingNameInput = useInput({
pattern: INPUT_FIELD_PATTERN.meetingName,
errorMessage: FIELD_DESCRIPTIONS.meetingName,
});
const isMeetingNameInvalid = checkInputInvalid(
meetingNameInput.value,
meetingNameInput.errorMessage,
);

const hostNickNameInput = useInput({
pattern: INPUT_FIELD_PATTERN.nickname,
errorMessage: FIELD_DESCRIPTIONS.nickname,
});
const hostPasswordInput = useInput({
pattern: INPUT_FIELD_PATTERN.password,
errorMessage: FIELD_DESCRIPTIONS.password,
});
const isHostInfoInvalid =
checkInputInvalid(hostNickNameInput.value, hostNickNameInput.errorMessage) ||
checkInputInvalid(hostPasswordInput.value, hostPasswordInput.errorMessage);

const [selectedDates, setSelectedDates] = useState<Set<string>>(new Set());

const hasDate = (date: string) => selectedDates.has(date);
const handleDateClick = (date: string) => {
setSelectedDates((prevDates) => {
const newSelectedDates = new Set(prevDates);
newSelectedDates.has(date) ? newSelectedDates.delete(date) : newSelectedDates.add(date);

return newSelectedDates;
});
};
const areDatesUnselected = selectedDates.size < 1;

const meetingTimeInput = useTimeRangeDropdown();

const isCreateMeetingFormInvalid =
isMeetingNameInvalid || (isHostInfoInvalid && areDatesUnselected);

const { mutation: postMeetingMutation } = usePostMeetingMutation();

const handleMeetingCreateButtonClick = () => {
const selectedDatesArray = Array.from(selectedDates);

postMeetingMutation.mutate({
meetingName: meetingNameInput.value,
hostName: hostNickNameInput.value,
hostPassword: hostPasswordInput.value,
availableMeetingDates: selectedDatesArray,
meetingStartTime: meetingTimeInput.startTime.value,
// 시간상 24시는 존재하지 않기 때문에 백엔드에서 오류가 발생. 따라서 오전 12:00으로 표현하지만, 서버에 00:00으로 전송(@낙타)
meetingEndTime:
meetingTimeInput.endTime.value === INITIAL_END_TIME
? INITIAL_START_TIME
: meetingTimeInput.endTime.value,
});
};

return {
meetingNameInput,
isMeetingNameInvalid,
hostNickNameInput,
hostPasswordInput,
isHostInfoInvalid,
hasDate,
handleDateClick,
meetingTimeInput,
isCreateMeetingFormInvalid,
handleMeetingCreateButtonClick,
};
};

export default useCreateMeeting;
26 changes: 26 additions & 0 deletions frontend/src/hooks/useFunnel/FunnelMain.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ReactElement } from 'react';
import React, { Children, isValidElement } from 'react';

import type { FunnelProps, StepProps, StepType } from './useFunnel.type';

const isValidFunnelChild = <Steps extends StepType>(
child: React.ReactNode,
): child is ReactElement<StepProps<Steps>> => {
return isValidElement(child) && typeof child.props.name === 'string';
};

export default function FunnelMain<Steps extends StepType>({
steps,
step,
children,
}: FunnelProps<Steps>) {
const childrenArray = Children.toArray(children)
.filter(isValidFunnelChild<Steps>)
.filter((child) => steps.includes(child.props.name));

const targetStep = childrenArray.find((child) => child.props.name === step);

if (!targetStep) return null;

return <>{targetStep}</>;
}
48 changes: 48 additions & 0 deletions frontend/src/hooks/useFunnel/useFunnel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import FunnelMain from './FunnelMain';
import type { RouteFunnelProps, StepProps, StepType } from './useFunnel.type';

const useFunnel = <Steps extends StepType>(steps: Steps, initialStep: Steps[number]) => {
const location = useLocation();
const navigate = useNavigate();

const setStep = (step: Steps[number]) => {
navigate(location.pathname, {
state: {
currentStep: step,
},
});
};

// 아직 헤더 디자인을 하지 않은 상태이기 때문에, goPrevStep은 사용하지 않는 상태입니다.(@해리)
const goPrevStep = () => {
navigate(-1);
};

const Step = <Steps extends StepType>({ children }: StepProps<Steps>) => {
return <>{children}</>;
};

// 컴포넌트가 다시 렌더링 될 때마다, Funnel 인스턴스가 다시 생성되는 문제가 있어서, useMemo로 감싸는 것으로 수정(@해리)
const Funnel = useMemo(
() =>
Object.assign(
function RouteFunnel(props: RouteFunnelProps<Steps>) {
const step =
(location.state as { currentStep?: Steps[number] })?.currentStep || initialStep;

return <FunnelMain<Steps> steps={steps} step={step} {...props} />;
},
{
Step,
},
),
[location.state, initialStep, steps],
);

return [setStep, Funnel] as const;
};

export default useFunnel;
16 changes: 16 additions & 0 deletions frontend/src/hooks/useFunnel/useFunnel.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ReactElement } from 'react';

export type StepType = Readonly<Array<string>>;

export interface FunnelProps<Steps extends StepType> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>>;
}

export type RouteFunnelProps<Steps extends StepType> = Omit<FunnelProps<Steps>, 'steps' | 'step'>;

export interface StepProps<Steps extends StepType> {
name: Steps[number];
children: React.ReactNode;
}
2 changes: 2 additions & 0 deletions frontend/src/hooks/useInput/useInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ const useInput = (rules?: ValidationRules) => {
};
};

export type UseInputReturn = ReturnType<typeof useInput>;

export default useInput;
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
isTimeSelectable,
} from './useTimeRangeDropdown.utils';

export default function useTimeRangeDropdown() {
const useTimeRangeDropdown = () => {
const [startTime, setStartTime] = useState(INITIAL_START_TIME);
const [endTime, setEndTime] = useState(INITIAL_END_TIME);

Expand Down Expand Up @@ -39,4 +39,8 @@ export default function useTimeRangeDropdown() {
handleStartTimeChange,
handleEndTimeChange,
} as const;
}
};

export type UseTimeRangeDropdownReturn = ReturnType<typeof useTimeRangeDropdown>;

export default useTimeRangeDropdown;
51 changes: 51 additions & 0 deletions frontend/src/pages/CreateMeetingFunnelPage/MeetingDateTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import TimeRangeSelector from '@components/TimeRangeSelector';
import BottomFixedButton from '@components/_common/Buttons/BottomFixedButton';
import Calendar from '@components/_common/Calendar';
import Field from '@components/_common/Field';

import type { UseTimeRangeDropdownReturn } from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown';

// 시작, 끝이 추가된 달력이랑 합쳐진 후, interface 수정예정(@해리)
interface MeetingDateInput {
hasDate: (date: string) => boolean;
onDateClick: (date: string) => void;
}

interface MeetingDateTimeProps {
meetingDateInput: MeetingDateInput;
meetingTimeInput: UseTimeRangeDropdownReturn;
isCreateMeetingFormInvalid: boolean;
onMeetingCreateButtonClick: () => void;
}

export default function MeetingDateTime({
meetingDateInput,
meetingTimeInput,
isCreateMeetingFormInvalid,
onMeetingCreateButtonClick,
}: MeetingDateTimeProps) {
const { hasDate, onDateClick } = meetingDateInput;
const { startTime, endTime, handleStartTimeChange, handleEndTimeChange } = meetingTimeInput;

return (
<>
<Field>
<Field.Label id="날짜선택" labelText="약속 날짜 선택" />
<Calendar hasDate={hasDate} onDateClick={onDateClick} />
</Field>

<Field>
<Field.Label id="약속시간범위선택" labelText="약속 시간 범위 선택" />
<TimeRangeSelector
startTime={startTime}
endTime={endTime}
handleStartTimeChange={handleStartTimeChange}
handleEndTimeChange={handleEndTimeChange}
/>
</Field>
<BottomFixedButton onClick={onMeetingCreateButtonClick} disabled={isCreateMeetingFormInvalid}>
약속 생성하기
</BottomFixedButton>
</>
);
}
Loading

0 comments on commit fdf752f

Please sign in to comment.