Skip to content

Commit

Permalink
refactor: Funnel 컴포넌트 리팩토링 (#500)
Browse files Browse the repository at this point in the history
* refactor: 퍼널 컴포넌트 리팩토링

* refactor: RoomNewPage의 퍼널 부분 리팩토링

* docs: Funnel 스토리북 수정

* refactor: initialStep 기능 추가
  • Loading branch information
bbearcookie authored Jan 30, 2024
1 parent df8dc5e commit 1f61f9f
Show file tree
Hide file tree
Showing 11 changed files with 142 additions and 122 deletions.
19 changes: 7 additions & 12 deletions src/domain/RoomNew/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { useFormContext } from 'react-hook-form';
import clsx from 'clsx';
import { steps } from '@/pages/RoomNewPage';
import { roomOptions } from '@/core/api/options';
import { FunnelHook } from '@/shared/Funnel/hooks/useFunnel';
import { useFunnel } from '@/shared/Funnel';
import { LoadingSpinner } from '@/shared/LoadingSpinner';
import { steps } from '@/pages/RoomNewPage';
import { Inputs } from '../hooks/useRoomForm';

interface NavbarProps extends FunnelHook<typeof steps> {
interface NavbarProps {
funnel: ReturnType<typeof useFunnel<typeof steps>>;
isPending: boolean;
}

const Navbar = ({
isPending,
current,
hasNext,
hasPrev,
toNext,
toPrev
}: NavbarProps) => {
const Navbar = ({ isPending, funnel }: NavbarProps) => {
const { trigger } = useFormContext<Inputs>();
const { step, hasNext, hasPrev, toNext, toPrev } = funnel;

// 참여 중인 방 정보를 가져오는 동안 다음 스텝으로 넘어가지 못하도록 합니다.
const { isSuccess } = useQuery({ ...roomOptions.myJoin() });
Expand All @@ -38,7 +33,7 @@ const Navbar = ({
return;
}

const isCompleted = await trigger(validationMaps[current], {
const isCompleted = await trigger(validationMaps[step], {
shouldFocus: true
});

Expand Down
16 changes: 8 additions & 8 deletions src/pages/RoomNewPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FormProvider } from 'react-hook-form';
import { motion } from 'framer-motion';
import { useFunnel, Funnel } from '@/shared/Funnel';
import { createFunnel } from '@/shared/Funnel';
import { Header } from '@/shared/Header';
import {
BirdStep,
Expand All @@ -20,18 +20,18 @@ export const steps = [
'SummaryStep'
] as const;

const stepComponents: {
[key in (typeof steps)[number]]: JSX.Element;
} = {
const stepComponents: Record<(typeof steps)[number], JSX.Element> = {
BirdStep: <BirdStep />,
TimeStep: <TimeStep />,
RoutineStep: <RoutineStep />,
PasswordStep: <PasswordStep />,
SummaryStep: <SummaryStep />
};

const { Funnel, Step, useFunnel } = createFunnel(steps);

const RoomNewPage = () => {
const funnel = useFunnel(steps);
const funnel = useFunnel();
const { form, mutation, handleSubmit } = useRoomForm();

return (
Expand All @@ -48,7 +48,7 @@ const RoomNewPage = () => {
<main className="grow overflow-auto px-8 py-12">
<Funnel {...funnel}>
{steps.map((step) => (
<Funnel.Step
<Step
key={step}
name={step}
>
Expand All @@ -60,12 +60,12 @@ const RoomNewPage = () => {
>
{stepComponents[step]}
</motion.div>
</Funnel.Step>
</Step>
))}
</Funnel>
</main>
<Navbar
{...funnel}
funnel={funnel}
isPending={mutation.isPending}
/>
</form>
Expand Down
43 changes: 32 additions & 11 deletions src/shared/Funnel/Funnel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,52 @@ import * as FunnelStories from './Funnel.stories';

## **사용 방법**

### 1. steps 정의
### 1. createFunnel 함수 호출

먼저 createFunnel 함수를 호출해서 `Funnel`, `Step`, `useFunnel` 를 가져와주세요.
createFunnel 함수는 step 배열 정보를 일일히 제네릭으로 넘겨주지 않아도 되도록 도와주는 헬퍼 함수입니다.

step 배열에는 **as const**를 붙여서 좁은 타입으로 만들어주세요.

```tsx
const steps = ["방선택", "인증시간", "루틴정보", "비밀번호", "마무리"] as const;
const { Funnel, Step, useFunnel } = createFunnel([
'방선택',
'인증시간',
'루틴정보',
'비밀번호',
'마무리'
] as const);
```

### 2. useFunnel 훅 선언

```tsx
const funnel = useFunnel(steps, <최초로 보여줄 스텝>);
const { step, hasNext, hasPrev, setStep, toNext, toPrev } = useFunnel();
```

### 3. Funnel, Step 컴포넌트 사용

렌더링하고자 하는 Step을 Funnel 컴포넌트로 감싸주세요.
그리고 Step 컴포넌트에는 name 속성을 넘겨주세요.

```tsx
<Funnel {...funnel}>
<Funnel.Step<typeof steps> name="마무리">마무리 페이지</Funnel.Step>
<Funnel.Step<typeof steps> name="방선택">방선택 페이지</Funnel.Step>
<Funnel.Step<typeof steps> name="인증시간">인증시간 페이지</Funnel.Step>
<Funnel.Step<typeof steps> name="루틴정보">루틴정보 페이지</Funnel.Step>
<Funnel.Step<typeof steps> name="비밀번호">비밀번호 페이지</Funnel.Step>
<Funnel step={step}>
<Step name="마무리">
<div>마무리 페이지</div>
<button onClick={() => setStep('방선택')}>처음으로 이동하기</button>
</Step>
<Step name="방선택">방선택 페이지</Step>
<Step name="인증시간">인증시간 페이지</Step>
<Step name="루틴정보">루틴정보 페이지</Step>
<Step name="비밀번호">비밀번호 페이지</Step>
<div>Step 컴포넌트가 아닌 요소는 렌더링에서 무시돼요.</div>
<div>
children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는
순서로 스텝을 보여줘요.
children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는 순서로
스텝을 보여줘요.
</div>
<div>
또는 각 스텝에서 다음 스텝으로 넘어가는 로직에 setStep을 사용하고 핸들러로
전달할 수 있어요.
</div>
</Funnel>
```
Expand Down
55 changes: 28 additions & 27 deletions src/shared/Funnel/Funnel.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Funnel, useFunnel } from '.';
import { createFunnel } from '.';

const meta = {
title: 'Shared/Funnel'
} satisfies Meta<typeof Funnel>;
} satisfies Meta;

export default meta;

Expand All @@ -13,53 +13,54 @@ export const Default: Story = {
render: () => <DefaultPage />
};

const DefaultPage = () => {
const steps = [
'방선택',
'인증시간',
'루틴정보',
'비밀번호',
'마무리'
] as const;
const { Funnel, Step, useFunnel } = createFunnel([
'방선택',
'인증시간',
'루틴정보',
'비밀번호',
'마무리'
] as const);

const funnel = useFunnel(steps, '인증시간');
const DefaultPage = () => {
const { step, hasNext, hasPrev, setStep, toNext, toPrev } = useFunnel();

return (
<div className="bg-slate-100">
<div className="flex min-h-[700px] items-center justify-center text-4xl">
<Funnel {...funnel}>
<Funnel.Step<typeof steps> name="마무리">마무리 페이지</Funnel.Step>
<Funnel.Step<typeof steps> name="비밀번호">
비밀번호 페이지
</Funnel.Step>
<Funnel.Step<typeof steps> name="루틴정보">
루틴정보 페이지
</Funnel.Step>
<Funnel.Step<typeof steps> name="인증시간">
인증시간 페이지
</Funnel.Step>
<Funnel.Step<typeof steps> name="방선택">방선택 페이지</Funnel.Step>
<Funnel step={step}>
<Step name="마무리">
<div>마무리 페이지</div>
<button onClick={() => setStep('방선택')}>처음으로 이동하기</button>
</Step>
<Step name="비밀번호">비밀번호 페이지</Step>
<Step name="루틴정보">루틴정보 페이지</Step>
<Step name="인증시간">인증시간 페이지</Step>
<Step name="방선택">방선택 페이지</Step>
<div>Step 컴포넌트가 아닌 요소는 렌더링에서 무시돼요.</div>
<div>
children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는
순서로 스텝을 보여줘요.
</div>
<div>
또는 각 스텝에서 다음 스텝으로 넘어가는 로직에 setStep을 사용하고
핸들러로 전달할 수 있어요.
</div>
</Funnel>
</div>
<div className="absolute bottom-0 w-full">
<div className="grid w-full grid-cols-2">
{funnel.hasPrev && (
{hasPrev && (
<button
className="btn btn-danger col-start-1 w-full rounded-none"
onClick={funnel.toPrev}
onClick={toPrev}
>
이전으로
</button>
)}
{funnel.hasNext && (
{hasNext && (
<button
className="btn btn-success col-start-2 w-full rounded-none"
onClick={funnel.toNext}
onClick={toNext}
>
다음으로
</button>
Expand Down
35 changes: 17 additions & 18 deletions src/shared/Funnel/components/Funnel.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
import React, { PropsWithChildren } from 'react';
import { StepNames } from '../types/funnel';
import Step from './Step';
import React from 'react';
import Step, { StepProps } from './Step';

interface FunnelProps<T extends StepNames> {
current: T[number];
interface FunnelProps<T extends readonly string[]> {
step: T[number];
children: React.ReactNode;
}

const Funnel = <T extends StepNames>({
current,
/** 하위 노드 중에서 렌더링 해야 할 Step 컴포넌트를 그리는 컴포넌트입니다. */
const Funnel = <T extends readonly string[]>({
step,
children
}: PropsWithChildren<FunnelProps<T>>) => {
}: FunnelProps<T>) => {
const validChildren = React.Children.toArray(children)
.filter<React.ReactElement>(React.isValidElement)
.filter((child) => child.type === Step);
.filter(React.isValidElement)
.filter((child) => child.type === Step) as React.ReactElement<
StepProps<T>
>[];

const currentStepIndex = validChildren.findIndex(
(step) => step.props.name === current
);
const currentStep = validChildren.find((child) => child.props.name === step);

if (currentStepIndex === -1) {
if (!currentStep) {
throw new Error(
'보여주려는 current 스텝이 Funnel의 children 중에 존재하지 않습니다.'
`Funnel의 children 중에서 ${step} 스텝이 존재하지 않습니다.`
);
}

return <>{validChildren[currentStepIndex]}</>;
return <>{currentStep}</>;
};

Funnel.Step = Step;

export default Funnel;
12 changes: 5 additions & 7 deletions src/shared/Funnel/components/Step.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { PropsWithChildren } from 'react';
import { StepNames } from '../types/funnel';

interface StepProps<T extends StepNames> {
export interface StepProps<T extends readonly string[]> {
/** Funnel 컴포넌트에서 렌더링 할 Step을 필터링하는데 사용됩니다. */
name: T[number];
children: React.ReactNode;
}

const Step = <T extends StepNames>({
children
}: PropsWithChildren<StepProps<T>>) => {
/** 퍼널에서 스텝 별로 렌더링 할 컴포넌트입니다. */
const Step = <T extends readonly string[]>({ children }: StepProps<T>) => {
return <>{children}</>;
};

Expand Down
28 changes: 28 additions & 0 deletions src/shared/Funnel/hooks/useFunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useState } from 'react';

const useFunnel = <T extends readonly string[]>(
steps: T,
initialStep: T[number] = steps[0]
) => {
const [step, setStep] = useState<T[number]>(initialStep);
const currentIdx = steps.indexOf(step);

const hasPrev = currentIdx > 0;
const hasNext = currentIdx < steps.length - 1;

const toPrev = () => {
if (!hasPrev) return;

setStep(steps[currentIdx - 1]);
};

const toNext = () => {
if (!hasNext) return;

setStep(steps[currentIdx + 1]);
};

return { step, setStep, hasPrev, toPrev, hasNext, toNext };
};

export default useFunnel;
37 changes: 0 additions & 37 deletions src/shared/Funnel/hooks/useFunnel.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion src/shared/Funnel/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { default as Funnel } from './components/Funnel';
export { default as createFunnel } from './utils/createFunnel';
export { default as useFunnel } from './hooks/useFunnel';
1 change: 0 additions & 1 deletion src/shared/Funnel/types/funnel.ts

This file was deleted.

Loading

0 comments on commit 1f61f9f

Please sign in to comment.