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

fix(react): SwithCase 인터페이스 개선 및 한글화 #622

Merged
merged 1 commit into from
Dec 3, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/hot-windows-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modern-kit/react': patch
---

fix(react): SwithCase 인터페이스 개선 - @ssi02014
2 changes: 1 addition & 1 deletion docs/docs/react/components/DebounceWrapper.mdx
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import { DebounceWrapper } from '@modern-kit/react';

# DebounceWrapper

자식 요소에서 발생하는 이벤트`(ex: Click Event)`를 debounce해주는 유틸 컴포넌트입니다.
자식 컴포넌트의 이벤트 핸들러에 디바운스(debounce)를 선언적으로 적용할 수 있는 컴포넌트입니다.

<br />

Original file line number Diff line number Diff line change
@@ -62,7 +62,7 @@ const TestComponentWithInput = ({ capture, wait }: TestComponentProps) => {
};

describe('DebounceWrapper Component', () => {
it('should debounce click event from child element', async () => {
it('자식 요소의 onClick 이벤트를 디바운스해야 합니다.', async () => {
const mockFn = vi.fn();
// https://github.com/testing-library/user-event/issues/833
const { user } = renderSetup(
@@ -90,7 +90,7 @@ describe('DebounceWrapper Component', () => {
expect(mockFn).toBeCalledTimes(2);
});

it('should debounce change event from child element', async () => {
it('자식 요소의 onChange 이벤트를 디바운스해야 합니다.', async () => {
const { user } = renderSetup(
<TestComponentWithInput capture="onChange" wait={500} />,
{ delay: null }
53 changes: 49 additions & 4 deletions packages/react/src/components/DebounceWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Children, cloneElement } from 'react';
import { DebounceParameters, useDebounce } from '../../hooks/useDebounce';
import { isFunction } from '@modern-kit/utils';

export interface DebounceWrapperProps {
children: JSX.Element;
@@ -8,28 +9,72 @@ export interface DebounceWrapperProps {
options?: DebounceParameters[2];
}

/**
* @description 자식 컴포넌트의 이벤트 핸들러에 디바운스를 선언적으로 적용할 수 있는 컴포넌트입니다.
*
* @param {DebounceWrapperProps} props - `DebounceWrapper` 컴포넌트의 속성
* @param {JSX.Element} props.children - 디바운스를 적용할 자식 컴포넌트
* @param {string} props.capture - 디바운스를 적용할 이벤트 핸들러 이름 (예: 'onClick', 'onChange')
* @param {number} props.wait - 디바운스가 적용될 시간(ms)입니다. 이 시간이 지나면 콜백이 실행됩니다.
* @param {object} props.options - 디바운스 동작에 영향을 주는 추가 옵션입니다.
*
* @returns {JSX.Element} - 디바운스가 적용된 자식 컴포넌트
*
* @example
* ```tsx
* // onClick debounce
* <DebounceWrapper capture="onClick" wait={300}>
* <button onClick={handleClick}>Button</button>
* </DebounceWrapper>
* ```
*
* @example
* ```tsx
* // onChange debounce
* const [debouncedValue, setDebouncedValue] = useState('');
*
* const onChange = (value: string) => {
* setDebouncedValue(value);
* };
*
* // 컴포넌트로 래핑이 필요합니다
* const Input = ({ onChange }: { onChange: (value: string) => void }) => {
* const [value, setValue] = useState('');
*
* const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
* setValue(e.target.value);
* onChange(e.target.value);
* };
*
* return <input onChange={handleChange} value={value} />;
* };
*
* <DebounceWrapper capture="onChange" wait={300}>
* <Input onChange={onChange} />
* </DebounceWrapper>
* ```
*/
export const DebounceWrapper = ({
children,
capture,
wait,
options,
}: DebounceWrapperProps) => {
// If children is a valid element, returns that element. Otherwise, throws an error.
}: DebounceWrapperProps): JSX.Element => {
const child = Children.only(children);

const debouncedCallback = useDebounce(
(...args: any[]) => {
const childProps = child?.props;

if (!childProps) return;
if (typeof childProps[capture] === 'function') {
if (isFunction(childProps[capture])) {
return childProps[capture](...args);
}
},
wait,
options
);

// cloneElement lets you create a new React element using another element as a starting point.
return cloneElement(child, {
[capture]: debouncedCallback,
});
71 changes: 31 additions & 40 deletions packages/react/src/components/SwitchCase/SwitchCase.test.tsx
Original file line number Diff line number Diff line change
@@ -3,50 +3,41 @@ import { screen } from '@testing-library/react';
import { renderSetup } from '../../_internal/test/renderSetup';
import { SwitchCase } from '.';

const TestComponent = ({ value }: { value: string }) => {
return (
<SwitchCase
value={value}
caseBy={{ a: <div>A</div>, b: <div>B</div> }}
defaultComponent={<div>Default</div>}
/>
);
};

describe('SwitchCase', () => {
it('should SwitchCase component receive condition.', () => {
renderSetup(
<SwitchCase condition={0} cases={{ 0: <button>case no.1</button> }} />
);
const CaseOneComponent = screen.queryByRole('button', {
name: 'case no.1',
});

expect(CaseOneComponent).toBeInTheDocument();
it(`value가 'a'일 때 'A'가 렌더링되어야 합니다.`, () => {
renderSetup(<TestComponent value="a" />);

expect(screen.getByText('A')).toBeInTheDocument();

expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('Default')).not.toBeInTheDocument();
});

it('should render defaultCaseComponent when there is no matched condition.', () => {
renderSetup(
<SwitchCase
condition={0 as number}
cases={{ 1: <button>case no.1</button> }}
defaultCaseComponent={<button>default component</button>}
/>
);

const CaseOneComponent = screen.queryByRole('button', {
name: 'case no.1',
});
const DefaultCaseComponent = screen.queryByRole('button', {
name: 'default component',
});

expect(CaseOneComponent).not.toBeInTheDocument();
expect(DefaultCaseComponent).toBeInTheDocument();
it(`value가 'b'일 때 'B'가 렌더링되어야 합니다.`, () => {
renderSetup(<TestComponent value="b" />);

expect(screen.getByText('B')).toBeInTheDocument();

expect(screen.queryByText('A')).not.toBeInTheDocument();
expect(screen.queryByText('Default')).not.toBeInTheDocument();
});

it('should render defaultCaseComponent when condition is nullable.', () => {
renderSetup(
<SwitchCase
condition={null}
cases={{}}
defaultCaseComponent={<button>default component</button>}
/>
);

const DefaultCaseComponent = screen.queryByRole('button', {
name: 'default component',
});
expect(DefaultCaseComponent).toBeInTheDocument();
it(`일치하는 value가 없을 때 'Default'가 렌더링되어야 합니다.`, () => {
renderSetup(<TestComponent value="cc" />);

expect(screen.getByText('Default')).toBeInTheDocument();

expect(screen.queryByText('A')).not.toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
});
});
46 changes: 32 additions & 14 deletions packages/react/src/components/SwitchCase/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
import { isNil } from '@modern-kit/utils';
import React from 'react';

interface SwitchCaseProps<Condition extends string | number> {
condition: Condition | null;
cases: Partial<Record<Condition, JSX.Element | null>>;
defaultCaseComponent?: JSX.Element | null;
interface SwitchCaseProps<Case extends PropertyKey> {
value: Case | null | undefined;
caseBy: Record<Case, React.ReactNode>;
defaultComponent?: React.ReactNode;
}

export const SwitchCase = <Condition extends string | number>({
condition,
cases,
defaultCaseComponent = null,
}: SwitchCaseProps<Condition>) => {
if (condition == null) {
return <React.Fragment>{defaultCaseComponent}</React.Fragment>;
/**
* @description value 값에 따라 다른 컴포넌트를 `Switch` 형태로 조건부 렌더링하는 컴포넌트입니다.
*
* @param {SwitchCaseProps<Case>} props - `SwitchCase` 컴포넌트의 속성
* @param {Case | null | undefined} props.value - 렌더링할 케이스를 결정하는 값
* @param {Record<Case, React.ReactNode>} props.caseBy - `value` 값에 대응하는 컴포넌트들을 담은 객체
* @param {React.ReactNode} props.defaultComponent - `value`가 `null`이거나 `caseBy`에 해당하는 컴포넌트가 없을 때 렌더링할 기본 컴포넌트
*
* @returns {React.ReactNode} - 조건부로 렌더링된 컴포넌트
*
* @example
* ```tsx
* <SwitchCase
* value={status}
* caseBy={{ success: <SuccessView />, error: <ErrorView /> }}
* defaultComponent={<DefaultView />}
* />
* ```
*/
export const SwitchCase = <Case extends PropertyKey>({
caseBy,
value,
defaultComponent = null,
}: SwitchCaseProps<Case>): React.ReactNode => {
if (isNil(value)) {
return defaultComponent;
}

return (
<React.Fragment>{cases[condition] ?? defaultCaseComponent}</React.Fragment>
);
return caseBy[value] ?? defaultComponent;
};
4 changes: 2 additions & 2 deletions packages/react/src/hooks/useDebounce/index.ts
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ export type DebounceReturnType<T extends DebounceParameters[0]> = ReturnType<
*
* @param {DebounceParameters[0]} callback - 디바운스 처리할 콜백 함수입니다.
* @param {DebounceParameters[1]} wait - 디바운스가 적용될 시간(ms)입니다. 이 시간이 지나면 콜백이 실행됩니다.
* @param {DebounceParameters[2]} [options={}] - 디바운스 동작에 영향을 주는 추가 옵션입니다. `leading(default: false)`, `trailing(default: true)`, `maxWait` 옵션을 받을 수 있습니다.
* @param {DebounceParameters[2]} [options] - 디바운스 동작에 영향을 주는 추가 옵션입니다. `leading(default: false)`, `trailing(default: true)`, `maxWait` 옵션을 받을 수 있습니다.
*
* @returns {DebounceReturnType<T>} 디바운스 처리된 콜백 함수를 반환합니다.
*
@@ -27,7 +27,7 @@ export type DebounceReturnType<T extends DebounceParameters[0]> = ReturnType<
export function useDebounce<T extends DebounceParameters[0]>(
callback: T,
wait: DebounceParameters[1],
options: DebounceParameters[2] = {}
options?: DebounceParameters[2]
): DebounceReturnType<T> {
const callbackAction = usePreservedCallback(callback);
const preservedOptions = usePreservedState(options);
Loading