Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] 약속 생성 흐름에 퍼널 패턴 적용 #356

Merged
merged 27 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7465cc1
chore: 퍼널 패턴에서 사용할 타입 정의
hwinkr Sep 21, 2024
90e3693
feat: 현재 렌더링 해야 할 자식 컴포넌트를 결정하는 FunnelMain 컴포넌트 구현
hwinkr Sep 21, 2024
d251414
feat: 복잡한 UI 흐름을 관리하는 useFunnel 커스텀 훅 구현
hwinkr Sep 21, 2024
caa868f
chore: useInput 커스텀 훅의 반환값 타입 정의
hwinkr Sep 21, 2024
fa23d7d
chore: useTimeRangeDropdown 커스텀 훅의 반환값 타입 정의
hwinkr Sep 21, 2024
56bb925
feat: 약속을 생성할 때 필요한 모든 지역 상태를 관리하는 useCreateMeeting 커스텀 훅 구현
hwinkr Sep 21, 2024
2ac567e
feat: 약속 이름을 입력받는 컴포넌트 구현
hwinkr Sep 21, 2024
11d1186
feat: 약속 주최자 정보(닉네임, 비밀번호)를 입력받는 컴포넌트 구현
hwinkr Sep 21, 2024
0e7adc5
feat: 약속 날짜, 시간 범위를 입력받는 컴포넌트 구현
hwinkr Sep 21, 2024
3c9f39c
feat: 뷰포트 하단에 고정되는 바텀 버튼 컴포넌트 구현
hwinkr Sep 21, 2024
2c3b09b
feat: 모바일 키보드 위로 바텀에 고정된 버튼이 올라올 수 있도록 하는 커스텀 훅 구현
hwinkr Sep 21, 2024
a0695ec
feat: 약속 생성 UI 흐름을 3단계로 구분하는 약속 생성 페이지 구현
hwinkr Sep 21, 2024
1a50636
design: 모바일 화면에서 스크롤이 되는 문제를 해결하기 위해서 height 속성값 수정
hwinkr Sep 21, 2024
08ff17c
chore: 사용하지 않는 html 태그 제거
hwinkr Oct 7, 2024
8d81b1b
chore: 바텀 고정 버튼 초기 높이 상수화
hwinkr Oct 7, 2024
61ac806
chore: RouteFunnel 컴포넌트 오타 수정
hwinkr Oct 7, 2024
301ebcd
chore: useInput 커스텀 훅의 반환 타입을 ReturnType 유틸리티 타입을 활용하는 것으로 수정
hwinkr Oct 7, 2024
9ee0f2c
chore: useTimeRangeDropdown 커스텀 훅의 반환 타입을 ReturnType 유틸리티 타입을 활용하는 것으…
hwinkr Oct 7, 2024
ed17243
refactor: 약속 생성 api 함수 추가, 입력 필드 유효성 검증 함수 추가 분리, invalid를 하나의 단어로 사용…
hwinkr Oct 7, 2024
b237cd8
chore: 입력 최소길이 상수 추가
hwinkr Oct 7, 2024
a0c1a03
chore: invalid를 하나의 단어로 사용해서 props 정의, api 요청 함수 추가
hwinkr Oct 7, 2024
b19834a
chore: 약속 생성 스텝 네이밍 상수화
hwinkr Oct 7, 2024
080f014
chore: invalid로 props 네이밍 수정
hwinkr Oct 7, 2024
2c31e0d
chore: invalid로 props 네이밍 수정, 약속 생성 api 핸들러 추가
hwinkr Oct 7, 2024
2d58971
chore: 사용하지 않는 hidden css 속성 제거
hwinkr Oct 7, 2024
2faa1aa
refactor: 선택된 날짜 상태 관리를 set 자료구조를 사용하는 것으로 수정
hwinkr Oct 7, 2024
5c5be28
chore: Invalid 단어 오타 수정
hwinkr Oct 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Q]

높이를 6rem으로 설정해주신 이유가 있을까요? 저는 잘 몰랐는데 합의된 내용이 있다면 공유하면 좋을 것 같고, 합의된 내용이 없다면 해당 부분에 대한 컨벤션을 얘기 나누어보면 좋을 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버튼 컴포넌트의 full variants를 사용하려고 했으나 6rem보다 height값이 작아 직접 높이를 정의했어요(@해리)

여기 이유를 작성했는데, 시각 자료가 부족했던 것 같군요

image

공통 버튼 컴포넌트의 full variants를 사용하려고 하니, 이미지처럼 너무 버튼의 height가 작은 문제가 있다고 판단했습니다. 적절한 height를 찾던 중 6rem이 괜찮은 것 같아서 해당 속성 값을 사용하는 것으로 대체하게 되었습니다.

저는 개인적으로 작업을 할 때, 중간중간 피드백을 받으면 서로의 작업에 영향을 줄 것 같아서 PR로 대화하는 것이 더 좋다고 생각하는 편인데 이런 관점에서는 중간중간 피드백을 받는게 나을수도 있겠다는 생각이 드네요. 어떻게 생각하시는지요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 언제든 피드백주셔도 좋습니다~! 해리가 편한 방법대로 하셔도 괜찮아요!

해리는 작업 중간에 대화가 오가는 것 보다 PR로 확인하는 것이 더 좋아보여서 중간 피드백보다는 반영 후 PR로 소통하는 편인 것으로 판단되는데, 저의 경우는 중간에 30번 찾아오셔도 괜찮습니다요~! 🙌

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Q]

오호 visualViewport가 어떤 역할을 하나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MDN-visualViewport에서도 확인할 수 있듯, 사용자의 실제 화면 영역입니다. 모바일의 경우 입력을 하기 위해서는 모바일 가상 키보드가 올라와야 하는데 이 때, 사용자가 실제로 볼 수 있는 화면의 영역의 크기가 달라지기 때문에 해당 뷰포트에 resize 이벤트 핸들러를 달아서 버튼이 모바일 키보드 위로 올라올 수 있도록 했습니다.


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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Q]

나중에 goPrevStep을 활용할 때는 return을 어떻게 구성하실 예정이신가요? 배열 형태이다보니 순서가 정해져있을 것 같아 궁금해 리뷰 남겨요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선은 서비스에 뒤로가기 버튼이 없기 때문에, useState 훅 처럼 배열 형태로 반환하도록 구현한 후 돌아가는지 빠르게 확인하고 PR을 날렸습니다. 피드백을 빠르게 받는 것이 좋다고 판단했어요. 그래서 뒤로가기 버튼이 생긴다면 객체 형태로 반환할 것 같네요~

};

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>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해리가 반영해주셔서 너무 기쁘네요 🥹


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