Skip to content

Commit

Permalink
[#498] useFunnel 작성 (#501)
Browse files Browse the repository at this point in the history
* feat: assert 유틸 함수 작성

* feat: useFunnel 커스텀 훅 작성

* feat: Funnel 컴포넌트 작성

* refactor: useFunnel 개선
  • Loading branch information
hanyugeon authored and gxxrxn committed Jun 17, 2024
1 parent 0043fe7 commit e6f7878
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 0 deletions.
88 changes: 88 additions & 0 deletions src/hooks/useFunnel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client';

import { useEffect, useMemo, useRef } from 'react';

import type { FunnelProps, StepProps } from '@/v1/base/Funnel/Funnel';
import { assert } from '@/utils/assert';

import { Funnel, Step } from '@/v1/base/Funnel/Funnel';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export type NonEmptyArray<T> = readonly [T, ...T[]];

type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<
FunnelProps<Steps>,
'steps' | 'step'
>;

type FunnelComponent<Steps extends NonEmptyArray<string>> = ((
props: RouteFunnelProps<Steps>
) => JSX.Element) & {
Step: (props: StepProps<Steps>) => JSX.Element;
};

const DEFAULT_STEP_QUERY_KEY = 'funnel-step';

/**
* 사용자에게 초기 step을 강제하고 싶을 땐
* option의 initialStep을 작성해 주세요.
*/
export const useFunnel = <Steps extends NonEmptyArray<string>>(
steps: Steps,
options?: {
stepQueryKey?: string;
initialStep?: Steps[number];
onStepChange?: (name: Steps[number]) => void;
}
): readonly [FunnelComponent<Steps>, (step: Steps[number]) => void] => {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();

const hasRunOnce = useRef(false);

const step = searchParams.get('funnel-step') as string;
const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;

useEffect(() => {
if (options?.initialStep && !hasRunOnce.current) {
hasRunOnce.current = true;
router.replace(pathname);
}
}, [options?.initialStep, router, pathname]);

assert(steps.length > 0, 'steps가 비어있습니다.');

const FunnelComponent = useMemo(
() =>
Object.assign(
function RouteFunnel(props: RouteFunnelProps<Steps>) {
const currentStep = step ?? options?.initialStep;

assert(
currentStep != null,
`표시할 스텝을 ${stepQueryKey} 쿼리 파라미터에 지정해주세요. 쿼리 파라미터가 없을 때 초기 스텝을 렌더하려면 useFunnel의 두 번째 파라미터 options에 initialStep을 지정해주세요.`
);

return <Funnel<Steps> steps={steps} step={currentStep} {...props} />;
},
{
Step,
}
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[step]
);

const setStep = (step: Steps[number]) => {
const params = new URLSearchParams(searchParams.toString());
params.set('funnel-step', `${step}`);

return router.replace(`?${params.toString()}`);
};

return [FunnelComponent, setStep] as unknown as readonly [
FunnelComponent<Steps>,
(step: Steps[number]) => Promise<void>
];
};
9 changes: 9 additions & 0 deletions src/utils/assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const assert = (condition: unknown, error: Error | string) => {
if (!condition) {
if (typeof error === 'string') {
throw new Error(error);
} else {
throw error;
}
}
};
42 changes: 42 additions & 0 deletions src/v1/base/Funnel/Funnel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Children, ReactElement, ReactNode, isValidElement } from 'react';

import type { NonEmptyArray } from '@/hooks/useFunnel';

import { assert } from '@/utils/assert';

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

export const Funnel = <Steps extends NonEmptyArray<string>>({
steps,
step,
children,
}: FunnelProps<Steps>) => {
const validChildren = Children.toArray(children)
.filter(isValidElement)
.filter(i =>
steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')
) as Array<ReactElement<StepProps<Steps>>>;

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

assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);

return <>{targetStep}</>;
};

export interface StepProps<Steps extends NonEmptyArray<string>> {
name: Steps[number];
children: ReactNode;
}

export const Step = <T extends NonEmptyArray<string>>({
children,
}: StepProps<T>) => {
return <>{children}</>;
};

0 comments on commit e6f7878

Please sign in to comment.