UNPKG

@toktokhan-dev/react-universal

Version:

A utility library for global use in React environments.

618 lines (602 loc) 20.8 kB
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 };