@toktokhan-dev/react-universal
Version:
A utility library for global use in React environments.
618 lines (602 loc) • 20.8 kB
JavaScript
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import dayjs from 'dayjs';
import { jsx, Fragment } from 'react/jsx-runtime';
import { createContext, useContextSelector } from 'use-context-selector';
import { isAxiosError } from 'axios';
import { produce } from 'immer';
/**
* @category Hooks
*/
const useCallbackRef = (callback) => {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const memoized = useCallback(((...params) => {
if (callbackRef.current) {
return callbackRef.current(...params);
}
}), []);
return memoized;
};
/**
* @category Utils/React
* 커스텀 훅을 기반으로 컨텍스트와 관련된 유틸리티를 생성하는 함수입니다.
* 이 함수는 주어진 훅을 컨텍스트로 감싸는 `Provider`, `useContext` 훅,
* 그리고 컴포넌트를 컨텍스트로 감싸는 `withProvider` HOC를 반환합니다.
*
* @template T - 컨텍스트에서 사용할 데이터 타입
* @template P - 훅의 파라미터 타입
* @param useHook - 컨텍스트에서 사용할 커스텀 훅
* @param initialProps - `useHook`에 전달될 초기 파라미터 (선택적)
* @returns `{ useContext, Provider, withProvider }` - 생성된 컨텍스트 유틸리티들
*
* @example
* ```tsx
* // 커스텀 훅 정의
* const useTimer = ({ timeLimit = 1000 }: { timeLimit?: number }) => {
* const [time, setTime] = useState(timeLimit);
* // 타이머 로직...
* return { time, start: () => { //타이머 시작 }};
* };
*
* // createContextSelector로 컨텍스트 유틸리티 생성
* const { Provider: TimerProvider, useContext: useTimerContext } = createContextSelector(useTimer);
*
* // 타이머를 표시하는 컴포넌트
* const TimerDisplay = () => {
* const time = useTimerContext(ctx => ctx.time);
* return <div>Time: {time}</div>;
* };
*
* // 방법 1. TimerProvider로 감싸기
* const App = () => (
* <TimerProvider params={{timeLimit: 1000}}>
* <TimerDisplay />
* </TimerProvider>
* );
*
* // 방법 2. withProvider로 컴포넌트 감싸기
* const App = () => (
* <TimerDisplay />
* );
* export default withTimerProvider(App, { timeLimit: 1000 });
* ```
*
*/
const createContextSelector = (useHook, initialProps) => {
const context = createContext({});
const useContext = (selector) => useContextSelector(context, selector);
const Provider = ({ children, params, }) => {
const value = useHook(params || initialProps);
return jsx(context.Provider, { value: value, children: children });
};
const withProvider = (Component, params) =>
// eslint-disable-next-line react/display-name
(props) => {
return (jsx(Provider, { params: params || initialProps, children: jsx(Component, Object.assign({}, props)) }));
};
return { useContext, Provider, withProvider };
};
/**
* 기본 시간 형식(mm:ss)으로 포맷팅하는 함수
* @param time - 밀리초 단위의 시간
* @example
* ```tsx
* import React from 'react';
* import { useTimer } from '@toktokhan-dev/react-universal';
*
* const TimerComponent = () => {
* const { time, isEnd, start, pause, reset } = useTimer({
* autoStart: false,
* timeLimit: 1000 * 60 * 5, // 5분
* });
*
* return (
* <div>
* <p>Remaining time: {time}</p>
* <button onClick={start}>Start Timer</button>
* <button onClick={pause}>Pause Timer</button>
* <button onClick={reset}>Reset Timer</button>
* {isEnd && <p>Timer ends</p>}
* </div>
* );
* };
*
* export default TimerComponent;
* ```
*/
const setDefaultTimeFormat = (time) => {
const durationObj = dayjs(Math.ceil(time / 1000) * 1000, 'millisecond').format('mm:ss');
return durationObj;
};
const initialProps = {
autoStart: true,
timeLimit: 1000 * 60,
interval: 1000,
setTimeFormat: setDefaultTimeFormat,
};
/**
* @category Hooks/useTimer()
* 타이머를 관리하는 커스텀 훅입니다.
*
*/
const useTimer = ({ autoStart = true, timeLimit = 1000 * 60, interval = 1000, setTimeFormat = setDefaultTimeFormat, onTimeOver, onTimeUpdate, } = initialProps) => {
const intervalRef = useRef(null);
const remainingTime = useRef(timeLimit);
const lastUpdate = useRef(null);
const onTimeOverRef = useCallbackRef(onTimeOver || (() => { }));
const onTimeUpdateRef = useCallbackRef(onTimeUpdate || (() => { }));
const [time, setTime] = useState(timeLimit);
const [status, setStatus] = useState('STOPPED');
/**
* 남은 시간을 업데이트하는 함수
* @returns 업데이트된 남은 시간
*/
const updateRemainingTime = useCallback(() => {
if (lastUpdate.current) {
const now = dayjs();
const nowDiff = now.diff(lastUpdate.current, 'milliseconds');
remainingTime.current = Math.max(0, remainingTime.current - nowDiff);
lastUpdate.current = dayjs();
}
return remainingTime.current;
}, []);
/**
* 타이머를 초기 상태로 리셋하는 함수
*/
const reset = useCallback(() => {
remainingTime.current = timeLimit;
lastUpdate.current = null;
setStatus('STOPPED');
setTime(timeLimit);
}, [timeLimit]);
/**
* 타이머를 시작하는 함수
*/
const start = useCallback(() => {
lastUpdate.current = dayjs();
setStatus('RUNNING');
}, []);
/**
* 타이머를 일시정지하는 함수
*/
const pause = useCallback(() => {
if (status === 'RUNNING') {
updateRemainingTime();
setStatus('PAUSED');
}
}, [status, updateRemainingTime]);
/**
* 타이머를 중지하는 함수
*/
const stop = useCallback(() => {
setStatus('STOPPED');
}, []);
/**
* 타이머를 재시작하는 함수
*/
const restart = useCallback(() => {
setTime(timeLimit);
setStatus('RUNNING');
remainingTime.current = timeLimit;
lastUpdate.current = dayjs();
}, [timeLimit]);
// autoStart가 false일 경우 time초기화
// true일 경우 start 함수 실행
useEffect(() => {
if (!autoStart)
return setTime(timeLimit);
start();
}, [autoStart, start, timeLimit]);
useEffect(() => {
if (status === 'RUNNING') {
const update = () => {
const newTime = updateRemainingTime();
setTime(newTime);
onTimeUpdateRef === null || onTimeUpdateRef === void 0 ? void 0 : onTimeUpdateRef(newTime);
if (newTime <= 0) {
stop();
onTimeOverRef === null || onTimeOverRef === void 0 ? void 0 : onTimeOverRef();
clearInterval(intervalRef.current);
}
};
update();
intervalRef.current = setInterval(update, interval);
return () => {
clearInterval(intervalRef.current);
};
}
clearInterval(intervalRef.current);
return () => {
clearInterval(intervalRef.current);
};
}, [
onTimeOverRef,
onTimeUpdateRef,
interval,
status,
stop,
updateRemainingTime,
]);
const timeOutput = useMemo(() => setTimeFormat(time), [setTimeFormat, time]);
return {
time: timeOutput,
isEnd: time <= 0,
start,
restart,
pause,
reset,
};
};
const TimerContextSelector = createContextSelector(useTimer, initialProps);
/**
* @category Hooks/useTimer()/Context(Optional)
* 이 프로바이더는 타이머 상태를 컨텍스트를 통해 지역/전역적으로 관리할 수 있도록 해줍니다.
*
* @remarks 컨텍스트를 사용하지 않아도 타이머 훅을 직접 사용할 수 있으며, 컨텍스트가 필요한 경우에만 사용하시기 바랍니다.
* 예를 들어, 다수의 컴포넌트에서 타이머 상태를 공유하거나, 전역적으로 타이머 상태를 관리해야 하는 경우에 유용합니다.
*
* @example
* ```tsx
* // TimerContainer.tsx
* import React from 'react';
* import { TimerProvider } from '@toktokhan-dev/react-universal';
* import TimerDisplay from './TimerDisplay';
*
* const TimerContainer = () => {
* return (
* <TimerProvider
* params={{
* autoStart: false,
* timeLimit: 1000 * 5,
* }}
* >
* <TimerDisplay />
* </TimerProvider>
* );
* };
*
* export default TimerContainer;
*
* // TimerDisplay.tsx
* const TimerDisplay = () => {
* // 불필요한 리랜더링 방지를 위해 selector로 가져오시는 것을 권장합니다.
* const time = useTimerContext((ctx) => ctx?.time)
* const start = useTimerContext((ctx) => ctx?.start)
*
* return (
* <div>
* <button onClick={start}>Start Timer</button>
* <p>Remaining Time: {time}</p>
* </div>
* );
* };
* ```
*/
const TimerProvider = TimerContextSelector.Provider;
/**
* @category Hooks/useTimer()/Context(Optional)
* 타이머 컨텍스트를 사용하는 커스텀 훅입니다. selector를 통해 컨텍스트의 값을 가져올 수 있습니다.
*
* @remarks 컨텍스트를 사용하지 않아도 타이머 훅을 직접 사용할 수 있으며, 컨텍스트가 필요한 경우에만 사용하시기 바랍니다.
* 예를 들어, 다수의 컴포넌트에서 타이머 상태를 공유하거나, 전역적으로 타이머 상태를 관리해야 하는 경우에 유용합니다.
*
* @example
* ```tsx
* import React from 'react';
* import { useTimerContext } from '@toktokhan-dev/react-universal';
*
* const TimerDisplay = () => {
* // 불필요한 리랜더링 방지를 위해 selector로 가져오시는 것을 권장합니다.
* const time = useTimerContext((ctx) => ctx?.time)
* const start = useTimerContext((ctx) => ctx?.start)
*
* return (
* <div>
* <button onClick={start}>Start Timer</button>
* <p>Remaining Time: {time}</p>
* </div>
* );
* };
*
* export default TimerDisplay;
* ```
*/
const useTimerContext = TimerContextSelector.useContext;
/**
* @category Hooks/useTimer()/Context(Optional)
* 타이머 컨텍스트를 제공하는 컴포넌트 HOC입니다.
* 이 HOC를 사용하여 컴포넌트를 래핑하면, 해당 컴포넌트와 하위 컴포넌트에서 타이머 상태를 공유할 수 있습니다.
*
* @remarks 컨텍스트를 사용하지 않아도 타이머 훅을 직접 사용할 수 있으며, 컨텍스트가 필요한 경우에만 사용하시기 바랍니다.
* 예를 들어, 다수의 컴포넌트에서 타이머 상태를 공유하거나, 전역적으로 타이머 상태를 관리해야 하는 경우에 유용합니다.
*
* @example
* ```tsx
* // TimerContainer.tsx
* import React from 'react';
* import { withTimerProvider, useTimerContext } from '@toktokhan-dev/react-universal';
*
* const TimerContainer = () => {
* return <TimerDisplay />;
* };
*
* export default withTimerProvider(TimerContainer, {
* autoStart: false,
* timeLimit: 1000 * 5,
* });
*
* // TimerDisplay.tsx
* const TimerDisplay = () => {
* // 불필요한 리랜더링 방지를 위해 selector로 가져오시는 것을 권장합니다.
* const time = useTimerContext((ctx) => ctx?.time)
* const start = useTimerContext((ctx) => ctx?.start)
*
* return (
* <div>
* <button onClick={start}>Start Timer</button>
* <p>Remaining Time: {time}</p>
* </div>
* );
* };
* ```
*/
const withTimerProvider = TimerContextSelector.withProvider;
/**
* 특정 로직을 동작시킬 때 비동기로 제어권을 양도하는 로직을 쉽게 사용하기 위해 구현한 hooks 입니다.
*
* endYield 함수의 인자로 값을 전달한다면 startYield의 반환값으로 사용할 수 있습니다.
*
* @category Hooks
*
* @returns startYield / endYield 함수
*
* @example
*
* ```tsx
*
* // hooks 선언
* const {endYield, startYield} = useYieldLogic();
*
* // 변경함수
* const onClickSubmit = async() => {
* // 만약 유의사항을 띄워야 한다면 유의사항 모달을 띄운 후, startYield를 호출하여 함수의 제어권을 넘깁니다.
* if (isPrecaution) {
* openPrecautionModal();
* const count = await startYield();
* }
*
* // 조회 API 호출로직...
* }
* const onClickPrecautionConfirm = () => {
* // 유의사항 모달에서 확인을 눌렀을 때 endYield를 호출하여 다시 제어권을 startYield 함수로 옮깁니다.
* endYield(3); // 인자의 유무는 자유이며, 여기서 전달한 인자를 startYield 에서 return받습니다.
* closePrecautionModal();
* }
*
*
* ```
*
*/
const useYieldLogic = () => {
const [resolve, setResolve] = useState();
const startYield = useCallback(() => {
return new Promise((resolve) => {
setResolve(() => resolve);
});
}, []);
const endYield = useCallback((v) => {
resolve === null || resolve === void 0 ? void 0 : resolve(v);
}, [resolve]);
return {
startYield,
endYield,
};
};
/**
* @param
* @category Utils/File
*
* 개선
*/
const fileToBase64 = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
/**
* @param
* @category Utils/Format
*/
const formatNumberKR = (num) => num.toLocaleString('ko-KR');
/**
* @param
* @category Utils/Format
*/
const formatPhoneNumberKR = (phone) => {
const phoneNumber = phone.replace(/[^0-9]/g, '');
const areaCode = phoneNumber.slice(0, 3);
const length = phoneNumber.length;
// exception for 2 digit area code
if (areaCode.indexOf('02') !== -1) {
if (length >= 3 && length <= 6) {
return phoneNumber.replace(/(\d{2})(\d)/, '$1-$2');
}
if (length > 6 && length < 10) {
return phoneNumber.replace(/(\d{2})(\d{3})(\d)/, '$1-$2-$3');
}
if (length >= 10) {
return phoneNumber
.slice(0, 10)
.replace(/(\d{2})(\d{4})(\d{4})/, '$1-$2-$3');
}
}
// phone or cell convert dash number
if (length >= 3 && length <= 6) {
return phoneNumber.replace(/(\d{3})(\d)/, '$1-$2');
}
if (length > 6 && length <= 10) {
return phoneNumber.replace(/(\d{3})(\d{3})(\d)/, '$1-$2-$3');
}
if (length >= 11) {
return phoneNumber.slice(0, 11).replace(/(\d{3})(\d{4})(\d)/, '$1-$2-$3');
}
return phoneNumber;
};
const defMessage = '에러가 발생했습니다. 고객센터에 문의해주세요.';
const isErrorMessageValid = (errorMsg) => {
if (typeof errorMsg !== 'object' || errorMsg === null) {
return false;
}
return Object.values(errorMsg).every((value) => Array.isArray(value) && value.every((item) => typeof item === 'string'));
};
const formatErrorMessage = (errorKey, errorMessage) => {
const message = errorMessage === null || errorMessage === void 0 ? void 0 : errorMessage.join('\n');
return `[${errorKey}]: ${message}`;
};
/**
* 서버에서 발생한 오류를 기반으로 에러 메세지 객체를 반환합니다.
* 외부 백엔드와 협업시에 에러타입을 확인해주세요.
* api logger에서 사용하고 있으며, 에러 메세지를 통해 toast, alert 등에 적용시킬 수 있습니다.
*
* @category Utils/Logger
* @template T - AxiosError 타입의 제네릭 매개변수입니다.
* @param errors - AxiosError 객체입니다.
* @returns 서버에서 발생한 오류에 기반한 에러메세지 객체입니다.
*/
const genErrorByServer = (errors) => {
var _a;
const errorMsg = (_a = errors.response) === null || _a === void 0 ? void 0 : _a.data.message;
if (!errorMsg || !isErrorMessageValid(errorMsg)) {
return { messagesWithKey: errors.message };
}
const formattedErrorsWithKey = Object.keys(errorMsg)
.filter((key) => key in errorMsg)
.map((key) => {
const errorKey = key;
const errorMessage = errorMsg[key];
return {
name: errorKey,
message: formatErrorMessage(errorKey, errorMessage),
};
});
const errorMessagesWithKey = formattedErrorsWithKey
.map((error) => error.message)
.join('\n');
const errorMessages = Object.values(errorMsg).join('\n');
return {
defMessage,
list: formattedErrorsWithKey,
messagesWithKey: errorMessagesWithKey,
messages: errorMessages,
};
};
/**
* @param
* @category Utils/Logger
*/
function styledConsole({ topic = '', title = '', data, topicColor = 'skyblue', method = 'log', errors = '', }) {
const term_1 = `%c[${topic}]`;
const term_1_style = [`color: ${topicColor}`, 'font-weight : bold'].join(';');
const term_2 = `%c${title}`;
const term_2_style = ['font-weight : bold'].join(';');
const error_msg = `%c${errors}`;
const error_style = [`color: ${topicColor}`, 'font-weight : bold'].join(';');
console[method](`${term_1}${term_2}`, term_1_style, term_2_style, data);
console[method](`${error_msg}`, error_style);
}
/**
* @param
* @category Utils/Logger
*/
const apiLogger = (params) => {
const { status, reqData, resData, method: consoleMethod = 'log' } = params;
const { method, url, params: urlPrams } = reqData || {};
const METHOD = method ? method.toUpperCase() : '';
const paramSerialized = params ? `?${new URLSearchParams(urlPrams).toString()}` : '';
const errors = (() => {
if (consoleMethod !== 'error')
return '';
if (!isAxiosError(resData))
return '';
const errors = genErrorByServer(resData);
return errors.messagesWithKey;
})();
styledConsole({
topic: `${METHOD}:${status}`,
topicColor: METHOD_COLOR_MAP[METHOD] || 'black',
title: `${url}${paramSerialized}`,
data: {
request: reqData,
response: resData,
},
method: consoleMethod,
errors,
});
};
const METHOD_COLOR_MAP = {
GET: 'skyblue',
PATCH: 'green',
POST: 'orange',
PUT: 'darkorange',
DELETE: 'red',
};
/**
* @param
* @category Utils/React
*/
const createSlice = ({ initialState, reducers, }) => {
const reducer = produce((state, action) => {
const reducer = reducers[action.type];
return reducer(state, action.payload);
});
return { initialState, reducer };
};
/**
* `EmptyView` 컴포넌트는 데이터가 비어있는 경우 `fallback`을,
* 데이터가 존재하는 경우 `children`을 렌더링합니다.
*
* @category Components
* @returns 조건에 따라 `children` 또는 `fallback`을 렌더링합니다.
* @example
* ```tsx
* import EmptyView from './components/StateViews/EmptyView';
*
* const MyComponent = ({ data }) => (
* <EmptyView data={data} fallback={<div>데이터가 없습니다.</div>}>
* <div>데이터가 존재합니다.</div>
* </EmptyView>
* );
* ```
*/
const EmptyView = ({ children, data, fallback }) => {
if (data === null || data === void 0 ? void 0 : data.length)
return jsx(Fragment, { children: children });
return jsx(Fragment, { children: fallback });
};
/**
* `LoadingView` 컴포넌트는 로딩 상태를 처리하여 로딩 중일 때는 `fallback`을,
* 로딩이 완료되었을 때는 `children`을 렌더링합니다.
*
* @category Components
* @returns 조건에 따라 `children` 또는 `fallback`을 렌더링합니다.
* @example
* ```tsx
* import LoadingView from './components/StateViews/LoadingView';
*
* const MyComponent = ({ isLoading }) => (
* <LoadingView isLoading={isLoading} fallback={<div>로딩 중...</div>}>
* <div>로딩이 완료되었습니다.</div>
* </LoadingView>
* );
* ```
*/
const LoadingView = ({ children, isLoading, fallback, }) => {
if (isLoading)
return jsx(Fragment, { children: fallback });
return jsx(Fragment, { children: children });
};
export { EmptyView, LoadingView, TimerProvider, apiLogger, createContextSelector, createSlice, defMessage, fileToBase64, formatNumberKR, formatPhoneNumberKR, genErrorByServer, styledConsole, useCallbackRef, useTimer, useTimerContext, useYieldLogic, withTimerProvider };