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

[#498] useFunnel 작성 #501

Merged
merged 4 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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}</>;
};
Loading